fix(app): stabilize timeline scrolling during updates
Publish real virtual-row measurements while suppressing momentum-breaking scroll writes, then reconcile the reader anchor once scrolling settles. Keep live patches out of the timeline during active gestures and add Electron coverage for tall-row overlap, residual motion, and existing-message updates.
This commit is contained in:
@@ -1,8 +1,15 @@
|
|||||||
export function createSessionTimelineScrollPolicy({ isUserScrolling, writeScroll }) {
|
export function createSessionTimelineScrollPolicy({
|
||||||
|
isUserScrolling,
|
||||||
|
writeScroll,
|
||||||
|
onSuppressedAdjustment = () => {},
|
||||||
|
}) {
|
||||||
let explicitDepth = 0;
|
let explicitDepth = 0;
|
||||||
|
|
||||||
function scrollToFn(offset, options = {}, instance) {
|
function scrollToFn(offset, options = {}, instance) {
|
||||||
if (explicitDepth === 0 && isUserScrolling()) {
|
if (explicitDepth === 0 && isUserScrolling()) {
|
||||||
|
if (Number.isFinite(options.adjustments) && options.adjustments !== 0) {
|
||||||
|
onSuppressedAdjustment(offset, options, instance);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
writeScroll(offset, options, instance);
|
writeScroll(offset, options, instance);
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { computed, ref } from 'vue';
|
import { computed, nextTick, ref } from 'vue';
|
||||||
import { defaultRangeExtractor, elementScroll, useVirtualizer } from '@tanstack/vue-virtual';
|
import {
|
||||||
|
defaultRangeExtractor,
|
||||||
|
elementScroll,
|
||||||
|
measureElement as measureVirtualElement,
|
||||||
|
useVirtualizer,
|
||||||
|
} from '@tanstack/vue-virtual';
|
||||||
import { createSessionTimelineScrollPolicy } from './session-timeline-scroll-policy.mjs';
|
import { createSessionTimelineScrollPolicy } from './session-timeline-scroll-policy.mjs';
|
||||||
|
|
||||||
function estimatedTextHeight(text = '') {
|
function estimatedTextHeight(text = '') {
|
||||||
@@ -74,6 +79,7 @@ export function resolveReaderAnchorIndex(anchor, items = []) {
|
|||||||
export function useSessionTimelineViewport({
|
export function useSessionTimelineViewport({
|
||||||
items,
|
items,
|
||||||
scrollElement,
|
scrollElement,
|
||||||
|
timelineElement,
|
||||||
scrollMargin,
|
scrollMargin,
|
||||||
overscan = 6,
|
overscan = 6,
|
||||||
gap = 14,
|
gap = 14,
|
||||||
@@ -81,9 +87,16 @@ export function useSessionTimelineViewport({
|
|||||||
userScroll,
|
userScroll,
|
||||||
}) {
|
}) {
|
||||||
const tailFollowReady = ref(false);
|
const tailFollowReady = ref(false);
|
||||||
|
let settlementActive = false;
|
||||||
|
let compensatedTimeline = null;
|
||||||
|
let originalTimelineTranslate = '';
|
||||||
|
let suppressedAdjustment = 0;
|
||||||
const scrollPolicy = createSessionTimelineScrollPolicy({
|
const scrollPolicy = createSessionTimelineScrollPolicy({
|
||||||
isUserScrolling: () => userScroll?.isActive() ?? false,
|
isUserScrolling: () => (
|
||||||
|
settlementActive || (userScroll?.isActive() ?? false)
|
||||||
|
),
|
||||||
writeScroll: elementScroll,
|
writeScroll: elementScroll,
|
||||||
|
onSuppressedAdjustment: applySuppressedAdjustment,
|
||||||
});
|
});
|
||||||
let virtualizer = null;
|
let virtualizer = null;
|
||||||
const rangeExtractor = createViewportRangeExtractor({
|
const rangeExtractor = createViewportRangeExtractor({
|
||||||
@@ -106,17 +119,151 @@ export function useSessionTimelineViewport({
|
|||||||
isScrollingResetDelay: 450,
|
isScrollingResetDelay: 450,
|
||||||
useScrollendEvent: true,
|
useScrollendEvent: true,
|
||||||
useAnimationFrameWithResizeObserver: true,
|
useAnimationFrameWithResizeObserver: true,
|
||||||
|
measureElement: measureVirtualElement,
|
||||||
scrollToFn: scrollPolicy.scrollToFn,
|
scrollToFn: scrollPolicy.scrollToFn,
|
||||||
})));
|
})));
|
||||||
|
|
||||||
const virtualRows = computed(() => virtualizer.value.getVirtualItems());
|
const virtualRows = computed(() => virtualizer.value.getVirtualItems());
|
||||||
const totalSize = computed(() => virtualizer.value.getTotalSize());
|
const totalSize = computed(() => virtualizer.value.getTotalSize());
|
||||||
|
|
||||||
|
function resolveTimelineElement(instance = virtualizer?.value) {
|
||||||
|
return timelineElement?.value
|
||||||
|
|| [...(instance?.elementsCache?.values?.() || [])]
|
||||||
|
.find(element => element.isConnected)?.parentElement
|
||||||
|
|| null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySuppressedAdjustment(_offset, options, instance) {
|
||||||
|
const adjustment = Number(options.adjustments) || 0;
|
||||||
|
if (adjustment === 0) return;
|
||||||
|
const target = resolveTimelineElement(instance);
|
||||||
|
if (!target) return;
|
||||||
|
if (compensatedTimeline !== target) {
|
||||||
|
if (compensatedTimeline) {
|
||||||
|
compensatedTimeline.style.translate = originalTimelineTranslate;
|
||||||
|
}
|
||||||
|
compensatedTimeline = target;
|
||||||
|
originalTimelineTranslate = target.style.translate || '';
|
||||||
|
suppressedAdjustment = 0;
|
||||||
|
}
|
||||||
|
suppressedAdjustment += adjustment;
|
||||||
|
target.style.translate = `0 ${-suppressedAdjustment}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSuppressedAdjustment() {
|
||||||
|
if (compensatedTimeline) {
|
||||||
|
compensatedTimeline.style.translate = originalTimelineTranslate;
|
||||||
|
}
|
||||||
|
compensatedTimeline = null;
|
||||||
|
originalTimelineTranslate = '';
|
||||||
|
suppressedAdjustment = 0;
|
||||||
|
}
|
||||||
|
|
||||||
function measureElement(element) {
|
function measureElement(element) {
|
||||||
if (!element) return;
|
if (!element) return;
|
||||||
virtualizer.value.measureElement(element);
|
virtualizer.value.measureElement(element);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function settleAfterUserScroll(commit = () => Promise.resolve()) {
|
||||||
|
if (settlementActive) return false;
|
||||||
|
const instance = virtualizer.value;
|
||||||
|
const element = scrollElement.value;
|
||||||
|
if (!instance || !element) {
|
||||||
|
clearSuppressedAdjustment();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollOffset = element.scrollTop;
|
||||||
|
const viewportRect = element.getBoundingClientRect();
|
||||||
|
const mountedRows = [...instance.elementsCache.entries()]
|
||||||
|
.filter(([, row]) => row.isConnected)
|
||||||
|
.map(([key, row]) => ({ key, row, rect: row.getBoundingClientRect() }));
|
||||||
|
const visibleAnchor = mountedRows
|
||||||
|
.filter(({ rect }) => (
|
||||||
|
rect.bottom > viewportRect.top && rect.top < viewportRect.bottom
|
||||||
|
))
|
||||||
|
.sort((left, right) => left.rect.top - right.rect.top)[0];
|
||||||
|
const fallbackMeasurement = instance.getVirtualItemForOffset(scrollOffset);
|
||||||
|
const anchor = visibleAnchor
|
||||||
|
? {
|
||||||
|
key: visibleAnchor.key,
|
||||||
|
screenOffset: visibleAnchor.rect.top - viewportRect.top,
|
||||||
|
}
|
||||||
|
: fallbackMeasurement
|
||||||
|
? {
|
||||||
|
key: fallbackMeasurement.key,
|
||||||
|
screenOffset: fallbackMeasurement.start - scrollOffset,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
settlementActive = true;
|
||||||
|
try {
|
||||||
|
// Publish the coalesced live patch inside the same geometry transaction.
|
||||||
|
// Real row sizes remain live throughout; only scrollTop corrections are
|
||||||
|
// suppressed until the reader anchor can be reconciled once.
|
||||||
|
await commit();
|
||||||
|
if (userScroll?.isActive() ?? false) return false;
|
||||||
|
|
||||||
|
const indexByKey = new Map(
|
||||||
|
items.value.map((item, index) => [item?.key || index, index]),
|
||||||
|
);
|
||||||
|
let appliedMeasurements = 0;
|
||||||
|
let stableFrames = 0;
|
||||||
|
for (let pass = 0; pass < 12 && stableFrames < 2; pass++) {
|
||||||
|
await nextTick();
|
||||||
|
if (userScroll?.isActive() ?? false) return false;
|
||||||
|
let changed = false;
|
||||||
|
const settledRows = [...instance.elementsCache.entries()]
|
||||||
|
.filter(([, row]) => row.isConnected)
|
||||||
|
.map(([key, row]) => ({ key, size: Math.round(row.getBoundingClientRect().height) }))
|
||||||
|
.filter(({ size }) => size > 0);
|
||||||
|
for (const { key, size } of settledRows) {
|
||||||
|
const index = indexByKey.get(key);
|
||||||
|
if (index === undefined) continue;
|
||||||
|
const cachedSize = instance.itemSizeCache.get(key)
|
||||||
|
?? instance.options.estimateSize(index);
|
||||||
|
if (cachedSize === size) continue;
|
||||||
|
instance.resizeItem(index, size);
|
||||||
|
appliedMeasurements++;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
stableFrames = changed ? 0 : stableFrames + 1;
|
||||||
|
const targetWindow = element.ownerDocument?.defaultView;
|
||||||
|
if (targetWindow) {
|
||||||
|
await new Promise(resolve => targetWindow.requestAnimationFrame(resolve));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetOffset = scrollOffset;
|
||||||
|
if (anchor) {
|
||||||
|
const anchorIndex = indexByKey.get(anchor.key);
|
||||||
|
const nextMeasurement = anchorIndex === undefined
|
||||||
|
? null
|
||||||
|
: instance.getMeasurements?.()[anchorIndex];
|
||||||
|
if (nextMeasurement) targetOffset = nextMeasurement.start - anchor.screenOffset;
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewportWasRepositioned = (userScroll?.isActive() ?? false)
|
||||||
|
|| Math.abs(element.scrollTop - scrollOffset) >= 1;
|
||||||
|
if (viewportWasRepositioned) {
|
||||||
|
clearSuppressedAdjustment();
|
||||||
|
instance.scrollOffset = element.scrollTop;
|
||||||
|
} else if (Math.abs(element.scrollTop - targetOffset) >= 0.5) {
|
||||||
|
clearSuppressedAdjustment();
|
||||||
|
instance.scrollOffset = targetOffset;
|
||||||
|
elementScroll(targetOffset, { behavior: 'auto' }, instance);
|
||||||
|
} else {
|
||||||
|
clearSuppressedAdjustment();
|
||||||
|
instance.scrollOffset = element.scrollTop;
|
||||||
|
}
|
||||||
|
return appliedMeasurements > 0;
|
||||||
|
} catch (error) {
|
||||||
|
clearSuppressedAdjustment();
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
settlementActive = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function indexAtViewportEnd(inset = 0) {
|
function indexAtViewportEnd(inset = 0) {
|
||||||
const instance = virtualizer.value;
|
const instance = virtualizer.value;
|
||||||
const viewportSize = instance.scrollRect?.height || scrollElement.value?.clientHeight || 0;
|
const viewportSize = instance.scrollRect?.height || scrollElement.value?.clientHeight || 0;
|
||||||
@@ -248,6 +395,7 @@ export function useSessionTimelineViewport({
|
|||||||
virtualRows,
|
virtualRows,
|
||||||
totalSize,
|
totalSize,
|
||||||
measureElement,
|
measureElement,
|
||||||
|
settleAfterUserScroll,
|
||||||
indexAtViewportEnd,
|
indexAtViewportEnd,
|
||||||
scrollToIndex,
|
scrollToIndex,
|
||||||
scrollToEnd,
|
scrollToEnd,
|
||||||
|
|||||||
@@ -67,19 +67,29 @@ const userScroll = createSessionUserScroll({ onEnd: handleUserScrollEnd });
|
|||||||
const timelineViewport = useSessionTimelineViewport({
|
const timelineViewport = useSessionTimelineViewport({
|
||||||
items: timelineItems,
|
items: timelineItems,
|
||||||
scrollElement: wrapRef,
|
scrollElement: wrapRef,
|
||||||
|
timelineElement: timelineRef,
|
||||||
scrollMargin: timelineScrollMargin,
|
scrollMargin: timelineScrollMargin,
|
||||||
scrollPaddingEnd: NAV_HEIGHT,
|
scrollPaddingEnd: NAV_HEIGHT,
|
||||||
userScroll,
|
userScroll,
|
||||||
});
|
});
|
||||||
const { virtualRows, totalSize, measureElement, waitForStableLayout } = timelineViewport;
|
const {
|
||||||
|
virtualRows,
|
||||||
|
totalSize,
|
||||||
|
measureElement,
|
||||||
|
settleAfterUserScroll,
|
||||||
|
waitForStableLayout,
|
||||||
|
} = timelineViewport;
|
||||||
const liveReloadCoordinator = createSessionLiveReloadCoordinator({
|
const liveReloadCoordinator = createSessionLiveReloadCoordinator({
|
||||||
isScrolling: () => userScroll.isActive(),
|
isScrolling: () => userScroll.isActive(),
|
||||||
load: loadLiveSnapshot,
|
load: loadLiveSnapshot,
|
||||||
commit: commitLiveSnapshot,
|
commit: commitLiveSnapshot,
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleUserScrollEnd() {
|
async function handleUserScrollEnd() {
|
||||||
if (active.value) void liveReloadCoordinator.flush();
|
if (!active.value) return;
|
||||||
|
await settleAfterUserScroll(() => (
|
||||||
|
active.value ? liveReloadCoordinator.flush() : Promise.resolve()
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncTimelineScrollMargin() {
|
function syncTimelineScrollMargin() {
|
||||||
|
|||||||
@@ -65,6 +65,14 @@ const messages = Array.from({ length: messageCount }, (_, index) => ({
|
|||||||
content_type: index === 1 ? 'tool_use' : 'text',
|
content_type: index === 1 ? 'tool_use' : 'text',
|
||||||
is_meta: 0,
|
is_meta: 0,
|
||||||
}));
|
}));
|
||||||
|
for (const startIndex of [96, 196]) {
|
||||||
|
for (let index = startIndex; index < startIndex + 8; index++) {
|
||||||
|
messages[index].text = Array.from(
|
||||||
|
{ length: 120 },
|
||||||
|
(_, paragraph) => `Unmeasured paragraph ${paragraph} for message ${index} stays visible while scrolling.`,
|
||||||
|
).join('\n\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
messages[focusMessageIndex].type = 'assistant';
|
messages[focusMessageIndex].type = 'assistant';
|
||||||
messages[focusMessageIndex].text = `Truncated preview ${'indexed content '.repeat(700)}`;
|
messages[focusMessageIndex].text = `Truncated preview ${'indexed content '.repeat(700)}`;
|
||||||
const fullTextSentinel = `FULL TEXT SENTINEL ${'complete content '.repeat(80)}`;
|
const fullTextSentinel = `FULL TEXT SENTINEL ${'complete content '.repeat(80)}`;
|
||||||
@@ -140,6 +148,98 @@ async function waitFor(webContents, expression, message, timeoutMs = 8000) {
|
|||||||
throw new Error(`Timed out waiting for ${message}`);
|
throw new Error(`Timed out waiting for ${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function probeOrdinaryScrollGeometry(win, { startIndex, direction }) {
|
||||||
|
await win.webContents.executeJavaScript(
|
||||||
|
`window.location.hash = '#/sessions/${sessionId}?focus=message-${startIndex}'`,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await waitFor(
|
||||||
|
win.webContents,
|
||||||
|
`document.querySelector('[data-uuid="message-${startIndex}"].is-focused')`,
|
||||||
|
`ordinary-scroll geometry start ${startIndex}`,
|
||||||
|
);
|
||||||
|
await delay(100);
|
||||||
|
return win.webContents.executeJavaScript(`new Promise(resolve => {
|
||||||
|
const wrap = document.querySelector('.detail-wrap');
|
||||||
|
const direction = ${direction};
|
||||||
|
const originalScrollTo = wrap.scrollTo.bind(wrap);
|
||||||
|
const blockAutomaticScrollEnd = event => event.stopImmediatePropagation();
|
||||||
|
let programmaticScrolls = 0;
|
||||||
|
let maxVisibleOverlaps = 0;
|
||||||
|
let overlapExample = null;
|
||||||
|
let previousGeometry = null;
|
||||||
|
let maxResidualMotion = 0;
|
||||||
|
let residualExample = null;
|
||||||
|
wrap.addEventListener('scrollend', blockAutomaticScrollEnd, true);
|
||||||
|
wrap.scrollTo = (...args) => {
|
||||||
|
programmaticScrolls++;
|
||||||
|
return originalScrollTo(...args);
|
||||||
|
};
|
||||||
|
wrap.dispatchEvent(new WheelEvent('wheel', { deltaY: direction * 70, bubbles: true }));
|
||||||
|
const startedAt = performance.now();
|
||||||
|
function frame(now) {
|
||||||
|
wrap.scrollTop += direction * 100;
|
||||||
|
const wrapRect = wrap.getBoundingClientRect();
|
||||||
|
const scrollTop = wrap.scrollTop;
|
||||||
|
const rows = [...document.querySelectorAll('.virtual-timeline-row')]
|
||||||
|
.map(row => {
|
||||||
|
const rect = row.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
index: Number(row.dataset.index),
|
||||||
|
uuid: row.querySelector('[data-uuid]')?.getAttribute('data-uuid'),
|
||||||
|
rect,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(({ rect }) => rect.bottom > wrapRect.top && rect.top < wrapRect.bottom)
|
||||||
|
.sort((left, right) => left.index - right.index);
|
||||||
|
let overlaps = 0;
|
||||||
|
for (let index = 1; index < rows.length; index++) {
|
||||||
|
if (rows[index].rect.top < rows[index - 1].rect.bottom - 1) overlaps++;
|
||||||
|
}
|
||||||
|
if (overlaps > maxVisibleOverlaps) {
|
||||||
|
maxVisibleOverlaps = overlaps;
|
||||||
|
overlapExample = rows.slice(0, 5).map(row => ({
|
||||||
|
index: row.index,
|
||||||
|
top: row.rect.top,
|
||||||
|
bottom: row.rect.bottom,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
const geometry = new Map(rows.filter(row => row.uuid).map(row => [
|
||||||
|
row.uuid,
|
||||||
|
row.rect.top - wrapRect.top,
|
||||||
|
]));
|
||||||
|
if (previousGeometry) {
|
||||||
|
for (const [uuid, top] of geometry) {
|
||||||
|
if (!previousGeometry.rows.has(uuid)) continue;
|
||||||
|
const screenDelta = top - previousGeometry.rows.get(uuid);
|
||||||
|
const scrollDelta = scrollTop - previousGeometry.scrollTop;
|
||||||
|
const residual = screenDelta + scrollDelta;
|
||||||
|
if (Math.abs(residual) > Math.abs(maxResidualMotion)) {
|
||||||
|
maxResidualMotion = residual;
|
||||||
|
residualExample = { uuid, screenDelta, scrollDelta, residual };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
previousGeometry = { rows: geometry, scrollTop };
|
||||||
|
if (now - startedAt < 2000) {
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wrap.scrollTo = originalScrollTo;
|
||||||
|
wrap.removeEventListener('scrollend', blockAutomaticScrollEnd, true);
|
||||||
|
wrap.dispatchEvent(new Event('scrollend'));
|
||||||
|
resolve({
|
||||||
|
programmaticScrolls,
|
||||||
|
maxVisibleOverlaps,
|
||||||
|
overlapExample,
|
||||||
|
maxResidualMotion,
|
||||||
|
residualExample,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
})`, true);
|
||||||
|
}
|
||||||
|
|
||||||
async function startRendererTrace(win, { captureScreenshots = false } = {}) {
|
async function startRendererTrace(win, { captureScreenshots = false } = {}) {
|
||||||
const traceEvents = [];
|
const traceEvents = [];
|
||||||
let completeTrace;
|
let completeTrace;
|
||||||
@@ -219,6 +319,8 @@ async function traceWheelPaintContinuity(win, { updateTool = false } = {}) {
|
|||||||
stop: false,
|
stop: false,
|
||||||
wheels: 0,
|
wheels: 0,
|
||||||
updateVisibleAtWheel: null,
|
updateVisibleAtWheel: null,
|
||||||
|
maxVisibleOverlaps: 0,
|
||||||
|
overlapExample: null,
|
||||||
};
|
};
|
||||||
const recordWheel = () => { probe.wheels++; };
|
const recordWheel = () => { probe.wheels++; };
|
||||||
const observer = new MutationObserver(() => {
|
const observer = new MutationObserver(() => {
|
||||||
@@ -237,6 +339,26 @@ async function traceWheelPaintContinuity(win, { updateTool = false } = {}) {
|
|||||||
function frame(now) {
|
function frame(now) {
|
||||||
probe.gaps.push(now - probe.previous);
|
probe.gaps.push(now - probe.previous);
|
||||||
probe.previous = now;
|
probe.previous = now;
|
||||||
|
const wrapRect = wrap.getBoundingClientRect();
|
||||||
|
const rows = [...document.querySelectorAll('.virtual-timeline-row')]
|
||||||
|
.map(row => ({
|
||||||
|
index: Number(row.dataset.index),
|
||||||
|
rect: row.getBoundingClientRect(),
|
||||||
|
}))
|
||||||
|
.filter(({ rect }) => rect.bottom > wrapRect.top && rect.top < wrapRect.bottom)
|
||||||
|
.sort((left, right) => left.index - right.index);
|
||||||
|
let overlaps = 0;
|
||||||
|
for (let index = 1; index < rows.length; index++) {
|
||||||
|
if (rows[index].rect.top < rows[index - 1].rect.bottom - 1) overlaps++;
|
||||||
|
}
|
||||||
|
if (overlaps > probe.maxVisibleOverlaps) {
|
||||||
|
probe.maxVisibleOverlaps = overlaps;
|
||||||
|
probe.overlapExample = rows.slice(0, 6).map(row => ({
|
||||||
|
index: row.index,
|
||||||
|
top: row.rect.top,
|
||||||
|
bottom: row.rect.bottom,
|
||||||
|
}));
|
||||||
|
}
|
||||||
if (!probe.stop) requestAnimationFrame(frame);
|
if (!probe.stop) requestAnimationFrame(frame);
|
||||||
}
|
}
|
||||||
requestAnimationFrame(frame);
|
requestAnimationFrame(frame);
|
||||||
@@ -280,7 +402,12 @@ async function traceWheelPaintContinuity(win, { updateTool = false } = {}) {
|
|||||||
probe.stop = true;
|
probe.stop = true;
|
||||||
probe.cleanup();
|
probe.cleanup();
|
||||||
delete window.__wheelFrameProbe;
|
delete window.__wheelFrameProbe;
|
||||||
return { gaps: probe.gaps, updateVisibleAtWheel: probe.updateVisibleAtWheel };
|
return {
|
||||||
|
gaps: probe.gaps,
|
||||||
|
updateVisibleAtWheel: probe.updateVisibleAtWheel,
|
||||||
|
maxVisibleOverlaps: probe.maxVisibleOverlaps,
|
||||||
|
overlapExample: probe.overlapExample,
|
||||||
|
};
|
||||||
})()`, true);
|
})()`, true);
|
||||||
const traceEvents = await stopRendererTrace();
|
const traceEvents = await stopRendererTrace();
|
||||||
const screenshots = traceEvents
|
const screenshots = traceEvents
|
||||||
@@ -296,6 +423,8 @@ async function traceWheelPaintContinuity(win, { updateTool = false } = {}) {
|
|||||||
maxTaskMs: taskMetrics.maxTaskMs,
|
maxTaskMs: taskMetrics.maxTaskMs,
|
||||||
maxFunctionCallMs: taskMetrics.maxFunctionCallMs,
|
maxFunctionCallMs: taskMetrics.maxFunctionCallMs,
|
||||||
updateVisibleAtWheel: frameProbe.updateVisibleAtWheel,
|
updateVisibleAtWheel: frameProbe.updateVisibleAtWheel,
|
||||||
|
maxVisibleOverlaps: frameProbe.maxVisibleOverlaps,
|
||||||
|
overlapExample: frameProbe.overlapExample,
|
||||||
slowestChildren: taskMetrics.slowestChildren,
|
slowestChildren: taskMetrics.slowestChildren,
|
||||||
// A blank content crop is almost uniform (< 0.035); rendered fixture rows
|
// A blank content crop is almost uniform (< 0.035); rendered fixture rows
|
||||||
// stay comfortably above 0.06 even while the compositor is scrolling.
|
// stay comfortably above 0.06 even while the compositor is scrolling.
|
||||||
@@ -623,6 +752,10 @@ async function run() {
|
|||||||
wheelPaint.blankFrames === 0,
|
wheelPaint.blankFrames === 0,
|
||||||
`fast wheel scrolling never presents a blank timeline frame (${JSON.stringify(wheelPaint)})`,
|
`fast wheel scrolling never presents a blank timeline frame (${JSON.stringify(wheelPaint)})`,
|
||||||
);
|
);
|
||||||
|
assert(
|
||||||
|
wheelBaseline.maxVisibleOverlaps === 0,
|
||||||
|
`ordinary wheel scrolling never overlaps visible rows (${JSON.stringify(wheelBaseline.overlapExample)})`,
|
||||||
|
);
|
||||||
assert(
|
assert(
|
||||||
wheelPaint.maxFrameGap < 50,
|
wheelPaint.maxFrameGap < 50,
|
||||||
`Bash tool update avoids a multi-frame renderer stall while scrolling (${JSON.stringify(wheelPaint)})`,
|
`Bash tool update avoids a multi-frame renderer stall while scrolling (${JSON.stringify(wheelPaint)})`,
|
||||||
@@ -735,6 +868,42 @@ async function run() {
|
|||||||
);
|
);
|
||||||
await delay(250);
|
await delay(250);
|
||||||
|
|
||||||
|
const downwardGeometry = await probeOrdinaryScrollGeometry(win, {
|
||||||
|
startIndex: 40,
|
||||||
|
direction: 1,
|
||||||
|
});
|
||||||
|
const upwardGeometry = await probeOrdinaryScrollGeometry(win, {
|
||||||
|
startIndex: 260,
|
||||||
|
direction: -1,
|
||||||
|
});
|
||||||
|
for (const [label, geometry] of [
|
||||||
|
['downward', downwardGeometry],
|
||||||
|
['upward', upwardGeometry],
|
||||||
|
]) {
|
||||||
|
assert(
|
||||||
|
geometry.maxVisibleOverlaps === 0,
|
||||||
|
`long rows never overlap during ordinary ${label} scrolling (${JSON.stringify(geometry.overlapExample)})`,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
geometry.programmaticScrolls === 0,
|
||||||
|
`ordinary ${label} scrolling performs no programmatic scrollTo writes`,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
Math.abs(geometry.maxResidualMotion) < 1.5,
|
||||||
|
`ordinary ${label} scrolling keeps visible messages fixed to scroll input (${JSON.stringify(geometry.residualExample)})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await win.webContents.executeJavaScript(
|
||||||
|
`window.location.hash = '#/sessions/${sessionId}?focus=${focusMessageUuid}'`,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await waitFor(
|
||||||
|
win.webContents,
|
||||||
|
`document.querySelector('[data-uuid="${focusMessageUuid}"].is-focused')`,
|
||||||
|
'restored offscreen UUID focus',
|
||||||
|
);
|
||||||
|
await delay(250);
|
||||||
|
|
||||||
await win.webContents.executeJavaScript(`(() => {
|
await win.webContents.executeJavaScript(`(() => {
|
||||||
const original = window.marked.parse;
|
const original = window.marked.parse;
|
||||||
const originalJsonParse = JSON.parse;
|
const originalJsonParse = JSON.parse;
|
||||||
@@ -845,20 +1014,53 @@ async function run() {
|
|||||||
const totalBeforeGesture = Number(document.querySelector('.flap-number')?.getAttribute('aria-label'));
|
const totalBeforeGesture = Number(document.querySelector('.flap-number')?.getAttribute('aria-label'));
|
||||||
const originalScrollTo = wrap.scrollTo.bind(wrap);
|
const originalScrollTo = wrap.scrollTo.bind(wrap);
|
||||||
let programmaticScrolls = 0;
|
let programmaticScrolls = 0;
|
||||||
|
let postScrollEndWrites = 0;
|
||||||
|
let phase = 'scrolling';
|
||||||
const blockAutomaticScrollEnd = event => event.stopImmediatePropagation();
|
const blockAutomaticScrollEnd = event => event.stopImmediatePropagation();
|
||||||
wrap.addEventListener('scrollend', blockAutomaticScrollEnd, true);
|
wrap.addEventListener('scrollend', blockAutomaticScrollEnd, true);
|
||||||
wrap.scrollTo = (...args) => {
|
wrap.scrollTo = (...args) => {
|
||||||
programmaticScrolls++;
|
if (phase === 'scrolling') programmaticScrolls++;
|
||||||
|
else postScrollEndWrites++;
|
||||||
return originalScrollTo(...args);
|
return originalScrollTo(...args);
|
||||||
};
|
};
|
||||||
const gaps = [];
|
const gaps = [];
|
||||||
const startedAt = performance.now();
|
const startedAt = performance.now();
|
||||||
let previous = startedAt;
|
let previous = startedAt;
|
||||||
wrap.dispatchEvent(new WheelEvent('wheel', { deltaY: 70, bubbles: true }));
|
let previousGeometry = null;
|
||||||
|
let maxResidualMotion = 0;
|
||||||
|
let residualExample = null;
|
||||||
|
wrap.dispatchEvent(new WheelEvent('wheel', { deltaY: -70, bubbles: true }));
|
||||||
|
function sampleGeometry(now) {
|
||||||
|
const wrapRect = wrap.getBoundingClientRect();
|
||||||
|
const scrollTop = wrap.scrollTop;
|
||||||
|
const rows = new Map([...document.querySelectorAll('.virtual-timeline-row')]
|
||||||
|
.map(row => {
|
||||||
|
const rect = row.getBoundingClientRect();
|
||||||
|
const uuid = row.querySelector('[data-uuid]')?.getAttribute('data-uuid');
|
||||||
|
return uuid && rect.bottom > wrapRect.top && rect.top < wrapRect.bottom
|
||||||
|
? [uuid, rect.top - wrapRect.top]
|
||||||
|
: null;
|
||||||
|
})
|
||||||
|
.filter(Boolean));
|
||||||
|
if (previousGeometry) {
|
||||||
|
for (const [uuid, top] of rows) {
|
||||||
|
if (!previousGeometry.rows.has(uuid)) continue;
|
||||||
|
const screenDelta = top - previousGeometry.rows.get(uuid);
|
||||||
|
const scrollDelta = scrollTop - previousGeometry.scrollTop;
|
||||||
|
const residual = screenDelta + scrollDelta;
|
||||||
|
if (Math.abs(residual) > Math.abs(maxResidualMotion)) {
|
||||||
|
maxResidualMotion = residual;
|
||||||
|
residualExample = { now: now - startedAt, uuid, screenDelta, scrollDelta, residual };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
previousGeometry = { rows, scrollTop };
|
||||||
|
}
|
||||||
function frame(now) {
|
function frame(now) {
|
||||||
gaps.push(now - previous);
|
gaps.push(now - previous);
|
||||||
previous = now;
|
previous = now;
|
||||||
if (now - startedAt >= 400) wrap.scrollTop += 70;
|
if (now - startedAt >= 400) wrap.scrollTop -= 70;
|
||||||
|
sampleGeometry(now);
|
||||||
if (now - startedAt < 1200) requestAnimationFrame(frame);
|
if (now - startedAt < 1200) requestAnimationFrame(frame);
|
||||||
else {
|
else {
|
||||||
const wrapRect = wrap.getBoundingClientRect();
|
const wrapRect = wrap.getBoundingClientRect();
|
||||||
@@ -870,7 +1072,11 @@ async function run() {
|
|||||||
const anchorElement = anchorRow?.querySelector('[data-uuid]');
|
const anchorElement = anchorRow?.querySelector('[data-uuid]');
|
||||||
const totalBeforeScrollEnd = Number(document.querySelector('.flap-number')?.getAttribute('aria-label'));
|
const totalBeforeScrollEnd = Number(document.querySelector('.flap-number')?.getAttribute('aria-label'));
|
||||||
const flapBeforeScrollEnd = Boolean(document.querySelector('.flap-slot.flipping'));
|
const flapBeforeScrollEnd = Boolean(document.querySelector('.flap-slot.flipping'));
|
||||||
wrap.scrollTo = originalScrollTo;
|
phase = 'settled';
|
||||||
|
window.__scrollGeometryWriteProbe = {
|
||||||
|
read: () => postScrollEndWrites,
|
||||||
|
restore: () => { wrap.scrollTo = originalScrollTo; },
|
||||||
|
};
|
||||||
wrap.removeEventListener('scrollend', blockAutomaticScrollEnd, true);
|
wrap.removeEventListener('scrollend', blockAutomaticScrollEnd, true);
|
||||||
wrap.dispatchEvent(new Event('scrollend'));
|
wrap.dispatchEvent(new Event('scrollend'));
|
||||||
resolve({
|
resolve({
|
||||||
@@ -878,6 +1084,8 @@ async function run() {
|
|||||||
totalBeforeScrollEnd,
|
totalBeforeScrollEnd,
|
||||||
flapBeforeScrollEnd,
|
flapBeforeScrollEnd,
|
||||||
programmaticScrolls,
|
programmaticScrolls,
|
||||||
|
maxResidualMotion,
|
||||||
|
residualExample,
|
||||||
maxFrameGap: Math.max(...gaps),
|
maxFrameGap: Math.max(...gaps),
|
||||||
frames: gaps.length,
|
frames: gaps.length,
|
||||||
rows: document.querySelectorAll('.virtual-timeline-row').length,
|
rows: document.querySelectorAll('.virtual-timeline-row').length,
|
||||||
@@ -906,6 +1114,13 @@ async function run() {
|
|||||||
`document.title.includes('Live metadata title') && document.querySelector('.breadcrumb')?.textContent.includes('Live metadata title')`,
|
`document.title.includes('Live metadata title') && document.querySelector('.breadcrumb')?.textContent.includes('Live metadata title')`,
|
||||||
'shared route metadata update',
|
'shared route metadata update',
|
||||||
);
|
);
|
||||||
|
const postScrollEndWrites = await win.webContents.executeJavaScript(`(() => {
|
||||||
|
const probe = window.__scrollGeometryWriteProbe;
|
||||||
|
const writes = probe?.read() ?? -1;
|
||||||
|
probe?.restore();
|
||||||
|
delete window.__scrollGeometryWriteProbe;
|
||||||
|
return writes;
|
||||||
|
})()`, true);
|
||||||
const sharedMetadataState = await win.webContents.executeJavaScript(`(() => ({
|
const sharedMetadataState = await win.webContents.executeJavaScript(`(() => ({
|
||||||
windowTitle: document.title.includes('Live metadata title'),
|
windowTitle: document.title.includes('Live metadata title'),
|
||||||
breadcrumb: document.querySelector('.breadcrumb')?.textContent.includes('Live metadata title'),
|
breadcrumb: document.querySelector('.breadcrumb')?.textContent.includes('Live metadata title'),
|
||||||
@@ -947,6 +1162,14 @@ async function run() {
|
|||||||
scrollProbe.programmaticScrolls === 0,
|
scrollProbe.programmaticScrolls === 0,
|
||||||
`wheel-to-scrollend performs zero programmatic scrollTo calls (got ${scrollProbe.programmaticScrolls})`,
|
`wheel-to-scrollend performs zero programmatic scrollTo calls (got ${scrollProbe.programmaticScrolls})`,
|
||||||
);
|
);
|
||||||
|
assert(
|
||||||
|
Math.abs(scrollProbe.maxResidualMotion) < 1.5,
|
||||||
|
`wheel-to-scrollend keeps visible messages fixed to scroll input (${JSON.stringify(scrollProbe.residualExample)})`,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
postScrollEndWrites <= 1,
|
||||||
|
`scrollend batches deferred measurements into at most one anchor sync (got ${postScrollEndWrites})`,
|
||||||
|
);
|
||||||
assert(!scrollProbe.flapBeforeScrollEnd, 'wheel-to-scrollend does not start the flap animation');
|
assert(!scrollProbe.flapBeforeScrollEnd, 'wheel-to-scrollend does not start the flap animation');
|
||||||
assert(scrollProbe.anchor, 'reader anchor is captured before the deferred live commit');
|
assert(scrollProbe.anchor, 'reader anchor is captured before the deferred live commit');
|
||||||
assert(
|
assert(
|
||||||
@@ -958,6 +1181,7 @@ async function run() {
|
|||||||
);
|
);
|
||||||
assert(scrollProbe.maxFrameGap < 250, `live scroll has no catastrophic long frame (${scrollProbe.maxFrameGap.toFixed(1)}ms)`);
|
assert(scrollProbe.maxFrameGap < 250, `live scroll has no catastrophic long frame (${scrollProbe.maxFrameGap.toFixed(1)}ms)`);
|
||||||
|
|
||||||
|
await waitFor(win.webContents, `!document.querySelector('.flap-slot.flipping')`, 'tail append flap settlement');
|
||||||
const updatedReaderText = `Updated ${scrollProbe.anchor.uuid} ${'content identity '.repeat(20)}`;
|
const updatedReaderText = `Updated ${scrollProbe.anchor.uuid} ${'content identity '.repeat(20)}`;
|
||||||
await win.webContents.executeJavaScript(`(() => {
|
await win.webContents.executeJavaScript(`(() => {
|
||||||
const original = window.marked.parse;
|
const original = window.marked.parse;
|
||||||
@@ -980,20 +1204,118 @@ async function run() {
|
|||||||
restore: () => { window.marked.parse = original; },
|
restore: () => { window.marked.parse = original; },
|
||||||
};
|
};
|
||||||
})()`, true);
|
})()`, true);
|
||||||
replaceMessageText(win, scrollProbe.anchor.uuid, updatedReaderText);
|
setTimeout(() => replaceMessageText(win, scrollProbe.anchor.uuid, updatedReaderText), 200);
|
||||||
|
const existingUpdateProbe = await win.webContents.executeJavaScript(`new Promise(resolve => {
|
||||||
|
const wrap = document.querySelector('.detail-wrap');
|
||||||
|
const targetUuid = ${JSON.stringify(scrollProbe.anchor.uuid)};
|
||||||
|
const targetText = ${JSON.stringify(updatedReaderText.slice(0, 40))};
|
||||||
|
const originalScrollTo = wrap.scrollTo.bind(wrap);
|
||||||
|
const blockAutomaticScrollEnd = event => event.stopImmediatePropagation();
|
||||||
|
let programmaticScrolls = 0;
|
||||||
|
let previousGeometry = null;
|
||||||
|
let maxResidualMotion = 0;
|
||||||
|
let residualExample = null;
|
||||||
|
let steps = 0;
|
||||||
|
wrap.addEventListener('scrollend', blockAutomaticScrollEnd, true);
|
||||||
|
wrap.scrollTo = (...args) => {
|
||||||
|
programmaticScrolls++;
|
||||||
|
return originalScrollTo(...args);
|
||||||
|
};
|
||||||
|
wrap.dispatchEvent(new WheelEvent('wheel', { deltaY: -40, bubbles: true }));
|
||||||
|
const startedAt = performance.now();
|
||||||
|
function frame(now) {
|
||||||
|
if (now - startedAt >= 250 && steps < 3) {
|
||||||
|
wrap.scrollTop -= 40;
|
||||||
|
steps++;
|
||||||
|
}
|
||||||
|
const wrapRect = wrap.getBoundingClientRect();
|
||||||
|
const scrollTop = wrap.scrollTop;
|
||||||
|
const rows = new Map([...document.querySelectorAll('.virtual-timeline-row')]
|
||||||
|
.map(row => {
|
||||||
|
const rect = row.getBoundingClientRect();
|
||||||
|
const uuid = row.querySelector('[data-uuid]')?.getAttribute('data-uuid');
|
||||||
|
return uuid && rect.bottom > wrapRect.top && rect.top < wrapRect.bottom
|
||||||
|
? [uuid, rect.top - wrapRect.top]
|
||||||
|
: null;
|
||||||
|
})
|
||||||
|
.filter(Boolean));
|
||||||
|
if (previousGeometry) {
|
||||||
|
for (const [uuid, top] of rows) {
|
||||||
|
if (!previousGeometry.rows.has(uuid)) continue;
|
||||||
|
const screenDelta = top - previousGeometry.rows.get(uuid);
|
||||||
|
const scrollDelta = scrollTop - previousGeometry.scrollTop;
|
||||||
|
const residual = screenDelta + scrollDelta;
|
||||||
|
if (Math.abs(residual) > Math.abs(maxResidualMotion)) {
|
||||||
|
maxResidualMotion = residual;
|
||||||
|
residualExample = { uuid, screenDelta, scrollDelta, residual };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
previousGeometry = { rows, scrollTop };
|
||||||
|
if (now - startedAt < 700) {
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const anchorRow = [...document.querySelectorAll('.virtual-timeline-row')]
|
||||||
|
.find(row => {
|
||||||
|
const rect = row.getBoundingClientRect();
|
||||||
|
return rect.bottom > wrapRect.top && rect.top < wrapRect.bottom;
|
||||||
|
});
|
||||||
|
const anchorElement = anchorRow?.querySelector('[data-uuid]');
|
||||||
|
const targetVisibleBeforeScrollEnd = Boolean(
|
||||||
|
document.querySelector('[data-uuid="' + targetUuid + '"]')?.textContent.includes(targetText),
|
||||||
|
);
|
||||||
|
wrap.scrollTo = originalScrollTo;
|
||||||
|
wrap.removeEventListener('scrollend', blockAutomaticScrollEnd, true);
|
||||||
|
wrap.dispatchEvent(new Event('scrollend'));
|
||||||
|
resolve({
|
||||||
|
targetVisibleBeforeScrollEnd,
|
||||||
|
programmaticScrolls,
|
||||||
|
maxResidualMotion,
|
||||||
|
residualExample,
|
||||||
|
anchor: anchorElement && {
|
||||||
|
uuid: anchorElement.getAttribute('data-uuid'),
|
||||||
|
offset: anchorRow.getBoundingClientRect().top - wrapRect.top,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
})`, true);
|
||||||
await waitFor(
|
await waitFor(
|
||||||
win.webContents,
|
win.webContents,
|
||||||
`document.querySelector('[data-uuid=${JSON.stringify(scrollProbe.anchor.uuid)}]')?.textContent.includes(${JSON.stringify(updatedReaderText.slice(0, 40))})`,
|
`document.querySelector('[data-uuid=${JSON.stringify(scrollProbe.anchor.uuid)}]')?.textContent.includes(${JSON.stringify(updatedReaderText.slice(0, 40))})`,
|
||||||
'visible message content update',
|
'visible message content update',
|
||||||
);
|
);
|
||||||
const contentIdentityCalls = await win.webContents.executeJavaScript(`(() => {
|
const updatedReaderState = await win.webContents.executeJavaScript(`(() => {
|
||||||
|
const wrap = document.querySelector('.detail-wrap');
|
||||||
|
const anchorElement = document.querySelector(
|
||||||
|
${JSON.stringify(`[data-uuid="${existingUpdateProbe.anchor?.uuid}"]`)},
|
||||||
|
);
|
||||||
|
const anchorRow = anchorElement?.closest('.virtual-timeline-row');
|
||||||
const calls = window.__timelineContentIdentityProbe.calls();
|
const calls = window.__timelineContentIdentityProbe.calls();
|
||||||
window.__timelineContentIdentityProbe.restore();
|
window.__timelineContentIdentityProbe.restore();
|
||||||
delete window.__timelineContentIdentityProbe;
|
delete window.__timelineContentIdentityProbe;
|
||||||
return calls;
|
return {
|
||||||
|
calls,
|
||||||
|
anchor: anchorElement && {
|
||||||
|
uuid: anchorElement.getAttribute('data-uuid'),
|
||||||
|
offset: anchorRow.getBoundingClientRect().top - wrap.getBoundingClientRect().top,
|
||||||
|
},
|
||||||
|
};
|
||||||
})()`, true);
|
})()`, true);
|
||||||
assert(contentIdentityCalls.target === 1, `updated mounted row recomputes its Markdown once (got ${contentIdentityCalls.target})`);
|
assert(!existingUpdateProbe.targetVisibleBeforeScrollEnd, 'existing message update stays out of the timeline until scrollend');
|
||||||
assert(contentIdentityCalls.unchanged === 0, `updated mounted row leaves other mounted Markdown cached (got ${contentIdentityCalls.unchanged})`);
|
assert(existingUpdateProbe.programmaticScrolls === 0, 'existing message update performs no programmatic scroll during the gesture');
|
||||||
|
assert(
|
||||||
|
Math.abs(existingUpdateProbe.maxResidualMotion) < 1.5,
|
||||||
|
`existing message update keeps visible messages fixed to scroll input (${JSON.stringify(existingUpdateProbe.residualExample)})`,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
existingUpdateProbe.anchor?.uuid === updatedReaderState.anchor?.uuid
|
||||||
|
&& Math.abs(existingUpdateProbe.anchor.offset - updatedReaderState.anchor.offset) < 2,
|
||||||
|
`existing message update preserves reader anchor ${existingUpdateProbe.anchor?.uuid} (${existingUpdateProbe.anchor?.offset}px -> ${updatedReaderState.anchor?.offset}px)`,
|
||||||
|
);
|
||||||
|
assert(updatedReaderState.calls.target === 1, `updated mounted row recomputes its Markdown once (got ${updatedReaderState.calls.target})`);
|
||||||
|
assert(updatedReaderState.calls.unchanged === 0, `updated mounted row leaves other mounted Markdown cached (got ${updatedReaderState.calls.unchanged})`);
|
||||||
assert(
|
assert(
|
||||||
ipcReads.messages === 1
|
ipcReads.messages === 1
|
||||||
&& ipcReads.toolCalls === 1
|
&& ipcReads.toolCalls === 1
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ test('virtualizer scroll writes are discarded throughout a user scroll', () => {
|
|||||||
let scrolling = true;
|
let scrolling = true;
|
||||||
const element = { scrollTop: 100 };
|
const element = { scrollTop: 100 };
|
||||||
const writes = [];
|
const writes = [];
|
||||||
|
const suppressed = [];
|
||||||
const instance = { scrollElement: element };
|
const instance = { scrollElement: element };
|
||||||
const policy = createSessionTimelineScrollPolicy({
|
const policy = createSessionTimelineScrollPolicy({
|
||||||
isUserScrolling: () => scrolling,
|
isUserScrolling: () => scrolling,
|
||||||
@@ -14,11 +15,19 @@ test('virtualizer scroll writes are discarded throughout a user scroll', () => {
|
|||||||
writes.push({ offset, ...options });
|
writes.push({ offset, ...options });
|
||||||
element.scrollTop = offset + (options.adjustments || 0);
|
element.scrollTop = offset + (options.adjustments || 0);
|
||||||
},
|
},
|
||||||
|
onSuppressedAdjustment: (offset, options) => {
|
||||||
|
suppressed.push({ offset, ...options });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
policy.scrollToFn(100, { behavior: 'auto', adjustments: 24 }, instance);
|
policy.scrollToFn(100, { behavior: 'auto', adjustments: 24 }, instance);
|
||||||
policy.scrollToFn(124, { behavior: 'auto' }, instance);
|
policy.scrollToFn(124, { behavior: 'auto' }, instance);
|
||||||
assert.deepEqual(writes, [], 'momentum is never interrupted by a programmatic write');
|
assert.deepEqual(writes, [], 'momentum is never interrupted by a programmatic write');
|
||||||
|
assert.deepEqual(
|
||||||
|
suppressed,
|
||||||
|
[{ offset: 100, behavior: 'auto', adjustments: 24 }],
|
||||||
|
'measurement adjustments are exposed for compositor compensation',
|
||||||
|
);
|
||||||
|
|
||||||
scrolling = false;
|
scrolling = false;
|
||||||
assert.equal(element.scrollTop, 100, 'scrollend never replays a suppressed correction');
|
assert.equal(element.scrollTop, 100, 'scrollend never replays a suppressed correction');
|
||||||
|
|||||||
Reference in New Issue
Block a user