mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): consolidate prepend scroll correction (#2855)
## Summary - make Virtua the sole prepend geometry/correction owner by accumulating active prepend ResizeObserver corrections from the live DOM offset - retire prepend reconciliation on ordinary reader wheel input, while preserving it for Ctrl+wheel browser zoom - remove Buzz's competing three-second semantic-anchor watcher and corrective `scrollBy` loop - keep ESM/CJS Virtua patch behavior equivalent and update the patch lock hash ## Why Buzz admitted prepended rows using seeded estimates, then Virtua received multiple measurement corrections for the same transaction. Each correction was based on the same stale model offset, so a later write replaced an earlier correction instead of accumulating it. In the reproduced first page, that resurrected 452px of anchor drift; the app-level watcher merely corrected the lost virtualizer write afterward. This fixes the correction inside Virtua and deletes the competing app writer, following the single-owner geometry invariant used by Berd rather than copying its spacer implementation. ## Validation - watcher-off desktop virtualization matrix: 11/11 passed, including 15 cascading prepends, continued wheel input, detached rich-row growth, channel switching, bottom follow, and buffered live arrivals - focused cascading prepend/Ctrl+wheel regression passed - desktop typecheck passed - desktop unit suite passed: 3,495 tests - Biome passed on changed desktop files - `git diff --check` clean ## Manual behavior Load older history repeatedly while scrolling upward, then wheel downward during/after a prepend. The visible anchor should stay within the existing 5px contract during reconciliation, and deliberate reader movement should not be pulled back. Ctrl+wheel during the prepend commit must not cancel reconciliation. --------- Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -335,6 +335,7 @@ const MessageTimelineBase = React.forwardRef<
|
||||
scrollContainerRef: activeScrollContainerRef,
|
||||
splitPanelOpen: splitThreadPanelOpen,
|
||||
targetMessageId,
|
||||
virtualCancelBottomIntent: timelineVirtualizerApi?.cancelBottomIntent,
|
||||
virtualScrollToMessage: timelineVirtualizerApi?.scrollToMessage,
|
||||
virtualScrollToBottom: timelineVirtualizerApi?.scrollToBottom,
|
||||
virtualSettleAtBottom: timelineVirtualizerApi?.settleAtBottom,
|
||||
|
||||
@@ -42,6 +42,7 @@ import { useUpwardPaginationWheel } from "./useUpwardPaginationWheel";
|
||||
import { useVirtualizedBottomSettle } from "./useVirtualizedBottomSettle";
|
||||
|
||||
export type TimelineVirtualizerApi = {
|
||||
cancelBottomIntent: () => void;
|
||||
scrollToBottom: (behavior?: ScrollBehavior) => void;
|
||||
settleAtBottom: () => void;
|
||||
scrollToMessage: (
|
||||
@@ -454,145 +455,39 @@ function VirtualizedTimelineRows({
|
||||
const keys = React.useMemo(() => items.map(virtualizedItemKey), [items]);
|
||||
itemsLengthRef.current = items.length;
|
||||
const previousKeysRef = React.useRef<readonly string[]>([]);
|
||||
const prependAnchorRef = React.useRef<{
|
||||
itemKey: string;
|
||||
top: number;
|
||||
} | null>(null);
|
||||
const prependWatcherFrameRef = React.useRef<number | null>(null);
|
||||
const [prependShiftEpoch, clearPrependShift] = React.useReducer(
|
||||
(version: number) => version + 1,
|
||||
0,
|
||||
);
|
||||
// Virtua's `shift` is a one-render instruction, not a persistent mode. If it
|
||||
// stays true after a prepend, later measurement changes can keep anchoring
|
||||
// from the end and leave a stale blank range until the next scroll event.
|
||||
const { cancel: cancelBottomSettle, settle: settleAtBottom } =
|
||||
useVirtualizedBottomSettle(hostRef, listRef, itemsLengthRef);
|
||||
const { arm: armUpwardMomentum } = useUpwardPaginationWheel(
|
||||
hostRef,
|
||||
cancelBottomSettle,
|
||||
);
|
||||
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
cancelBottomSettle();
|
||||
},
|
||||
[cancelBottomSettle],
|
||||
);
|
||||
|
||||
const isPrepend = React.useMemo(() => {
|
||||
void prependShiftEpoch;
|
||||
return didPrependVirtualizedTimeline(previousKeysRef.current, keys);
|
||||
}, [keys, prependShiftEpoch]);
|
||||
|
||||
const retirePrependAnchor = React.useCallback(() => {
|
||||
if (prependWatcherFrameRef.current !== null) {
|
||||
cancelAnimationFrame(prependWatcherFrameRef.current);
|
||||
}
|
||||
prependWatcherFrameRef.current = null;
|
||||
prependAnchorRef.current = null;
|
||||
}, []);
|
||||
const { cancel: cancelBottomSettle, settle: settleAtBottom } =
|
||||
useVirtualizedBottomSettle(hostRef, listRef, itemsLengthRef);
|
||||
const retireTimelineSettle = React.useCallback(() => {
|
||||
retirePrependAnchor();
|
||||
cancelBottomSettle();
|
||||
}, [cancelBottomSettle, retirePrependAnchor]);
|
||||
const { arm: armUpwardMomentum, clear: clearUpwardMomentum } =
|
||||
useUpwardPaginationWheel(hostRef, retireTimelineSettle);
|
||||
|
||||
const capturePrependAnchor = React.useCallback(() => {
|
||||
// Keep the pending capture current while the fetch is in flight. Once the
|
||||
// prepend commits and the watcher starts, its baseline is frozen.
|
||||
if (prependWatcherFrameRef.current !== null) return;
|
||||
const scroller = hostRef.current?.firstElementChild;
|
||||
if (!(scroller instanceof HTMLDivElement)) return;
|
||||
const scrollerTop = scroller.getBoundingClientRect().top;
|
||||
const row = Array.from(
|
||||
scroller.querySelectorAll<HTMLElement>("[data-timeline-item-key]"),
|
||||
).find(
|
||||
(candidate) => candidate.getBoundingClientRect().top >= scrollerTop - 1,
|
||||
);
|
||||
const itemKey = row?.dataset.timelineItemKey;
|
||||
if (!row || !itemKey) return;
|
||||
prependAnchorRef.current = {
|
||||
itemKey,
|
||||
top: row.getBoundingClientRect().top - scrollerTop,
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!isPrepend || !prependAnchorRef.current) return;
|
||||
// Virtua's shift mode correctly absorbs prepended measurements, but
|
||||
// estimated offsets can misclassify late row growth deep in history. Keep
|
||||
// the semantic row identity as a short-lived, deviation-gated backstop.
|
||||
// Do not correct in this commit: Virtua has shifted its estimate but has not
|
||||
// applied its ResizeObserver batch yet, so that delta is transient and its
|
||||
// subsequent absolute correction would overwrite our relative write.
|
||||
// This watcher deliberately survives a temporary row unmount and waits for
|
||||
// stable geometry so Virtua remains the primary scroll owner.
|
||||
if (prependWatcherFrameRef.current !== null) {
|
||||
cancelAnimationFrame(prependWatcherFrameRef.current);
|
||||
}
|
||||
const anchor = prependAnchorRef.current;
|
||||
const deadline = performance.now() + 3_000;
|
||||
let previousScrollTop: number | null = null;
|
||||
let settledFrames = 0;
|
||||
|
||||
const watch = () => {
|
||||
const scroller = hostRef.current?.firstElementChild;
|
||||
if (!(scroller instanceof HTMLDivElement)) {
|
||||
prependWatcherFrameRef.current = null;
|
||||
prependAnchorRef.current = null;
|
||||
return;
|
||||
}
|
||||
const atBottom =
|
||||
scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop <=
|
||||
32;
|
||||
const row = Array.from(
|
||||
scroller.querySelectorAll<HTMLElement>("[data-timeline-item-key]"),
|
||||
).find(
|
||||
(candidate) => candidate.dataset.timelineItemKey === anchor.itemKey,
|
||||
);
|
||||
const top = row
|
||||
? row.getBoundingClientRect().top - scroller.getBoundingClientRect().top
|
||||
: null;
|
||||
const scrollTop = scroller.scrollTop;
|
||||
settledFrames =
|
||||
previousScrollTop !== null &&
|
||||
Math.abs(scrollTop - previousScrollTop) < 0.5
|
||||
? settledFrames + 1
|
||||
: 0;
|
||||
previousScrollTop = scrollTop;
|
||||
|
||||
if (row && top !== null && settledFrames >= 2) {
|
||||
const delta = top - anchor.top;
|
||||
if (Math.abs(delta) > 4) {
|
||||
scroller.scrollBy({ top: delta });
|
||||
settledFrames = 0;
|
||||
previousScrollTop = null;
|
||||
}
|
||||
}
|
||||
|
||||
const retired =
|
||||
performance.now() >= deadline ||
|
||||
atBottom ||
|
||||
(top !== null && top > scroller.clientHeight * 2);
|
||||
if (retired) {
|
||||
retirePrependAnchor();
|
||||
return;
|
||||
}
|
||||
prependWatcherFrameRef.current = requestAnimationFrame(watch);
|
||||
};
|
||||
prependWatcherFrameRef.current = requestAnimationFrame(watch);
|
||||
clearUpwardMomentum();
|
||||
}, [clearUpwardMomentum, isPrepend, retirePrependAnchor]);
|
||||
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
retirePrependAnchor();
|
||||
cancelBottomSettle();
|
||||
},
|
||||
[cancelBottomSettle, retirePrependAnchor],
|
||||
);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
previousKeysRef.current = keys;
|
||||
if (isPrepend) {
|
||||
cancelBottomSettle();
|
||||
clearPrependShift();
|
||||
}
|
||||
if (!hasInitialPositionedRef.current && items.length > 0) {
|
||||
hasInitialPositionedRef.current = true;
|
||||
settleAtBottom();
|
||||
}
|
||||
}, [cancelBottomSettle, isPrepend, items.length, keys, settleAtBottom]);
|
||||
}, [isPrepend, items.length, keys, settleAtBottom]);
|
||||
|
||||
const messageItemIndexById = React.useMemo(() => {
|
||||
const byId = new Map<string, number>();
|
||||
@@ -622,16 +517,13 @@ function VirtualizedTimelineRows({
|
||||
React.useLayoutEffect(() => {
|
||||
if (!onVirtualizerApiChange) return;
|
||||
const api: TimelineVirtualizerApi = {
|
||||
cancelBottomIntent: cancelBottomSettle,
|
||||
scrollToBottom() {
|
||||
retireTimelineSettle();
|
||||
const lastIndex = itemsLengthRef.current - 1;
|
||||
if (lastIndex >= 0) {
|
||||
listRef.current?.scrollToIndex(lastIndex, { align: "end" });
|
||||
}
|
||||
settleAtBottom();
|
||||
},
|
||||
settleAtBottom,
|
||||
scrollToMessage(messageId) {
|
||||
retireTimelineSettle();
|
||||
cancelBottomSettle();
|
||||
const index = messageItemIndexByIdRef.current.get(messageId);
|
||||
if (index === undefined) return false;
|
||||
listRef.current?.scrollToIndex(index, { align: "center" });
|
||||
@@ -640,7 +532,7 @@ function VirtualizedTimelineRows({
|
||||
};
|
||||
onVirtualizerApiChange(api);
|
||||
return () => onVirtualizerApiChange(null);
|
||||
}, [onVirtualizerApiChange, retireTimelineSettle, settleAtBottom]);
|
||||
}, [cancelBottomSettle, onVirtualizerApiChange, settleAtBottom]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const host = hostRef.current;
|
||||
@@ -670,15 +562,13 @@ function VirtualizedTimelineRows({
|
||||
if (!list || !(scroller instanceof HTMLDivElement)) return;
|
||||
onVirtualizerRangeChanged?.();
|
||||
const distanceFromBottom = list.scrollSize - list.viewportSize - offset;
|
||||
if (distanceFromBottom > 32) cancelBottomSettle();
|
||||
// Do not infer reader intent from an intermediate virtualizer offset.
|
||||
// Initial channel positioning deliberately chases the floor while rows
|
||||
// are measured; those measurements can briefly report a large gap and
|
||||
// emit `onScroll` without any user input. Cancelling here strands the
|
||||
// channel above its newest message. The settle hook's wheel, pointer,
|
||||
// touch, and key listeners are the authoritative user-interaction gate.
|
||||
onAtBottomStateChange?.(distanceFromBottom <= 32);
|
||||
if (
|
||||
prependAnchorRef.current !== null ||
|
||||
offset <= 200 ||
|
||||
prependWatcherFrameRef.current === null
|
||||
) {
|
||||
capturePrependAnchor();
|
||||
}
|
||||
if (offset <= 200) {
|
||||
// Layout scrolls near the top must not poison the reader's next input.
|
||||
armUpwardMomentum(onStartReached?.() ?? false);
|
||||
@@ -686,8 +576,6 @@ function VirtualizedTimelineRows({
|
||||
},
|
||||
[
|
||||
armUpwardMomentum,
|
||||
cancelBottomSettle,
|
||||
capturePrependAnchor,
|
||||
onAtBottomStateChange,
|
||||
onStartReached,
|
||||
onVirtualizerRangeChanged,
|
||||
|
||||
@@ -48,6 +48,10 @@ function installDOMShim() {
|
||||
return this.children[0] ?? null;
|
||||
}
|
||||
|
||||
get firstElementChild() {
|
||||
return this.children[0] ?? null;
|
||||
}
|
||||
|
||||
get lastChild() {
|
||||
return this.children.at(-1) ?? null;
|
||||
}
|
||||
@@ -122,8 +126,16 @@ function installDOMShim() {
|
||||
}
|
||||
|
||||
globalThis.document = new DocumentShim();
|
||||
const windowEvents = new EventTargetShim();
|
||||
globalThis.addEventListener =
|
||||
windowEvents.addEventListener.bind(windowEvents);
|
||||
globalThis.removeEventListener =
|
||||
windowEvents.removeEventListener.bind(windowEvents);
|
||||
globalThis.dispatchEvent = windowEvents.dispatchEvent.bind(windowEvents);
|
||||
globalThis.HTMLIFrameElement = NodeShim;
|
||||
globalThis.HTMLDivElement = NodeShim;
|
||||
globalThis.HTMLElement = NodeShim;
|
||||
globalThis.Node = NodeShim;
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
process.env.IS_REACT_ACT_ENVIRONMENT = "true";
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
@@ -142,6 +154,7 @@ import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import { useAnchoredScroll } from "./useAnchoredScroll.ts";
|
||||
import { useVirtualizedBottomSettle } from "./useVirtualizedBottomSettle.ts";
|
||||
|
||||
function makePinnedCenterNodes() {
|
||||
const resizeObservers = [];
|
||||
@@ -232,6 +245,32 @@ function Harness({ channelId, refs }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function VirtualTargetHarness({ refs }) {
|
||||
const didRun = React.useRef(false);
|
||||
const bottomApi = useVirtualizedBottomSettle(
|
||||
refs.host,
|
||||
refs.list,
|
||||
refs.itemsLength,
|
||||
);
|
||||
const anchored = useAnchoredScroll({
|
||||
channelId: "conversation",
|
||||
contentRef: refs.content,
|
||||
isLoading: false,
|
||||
messages: Array.from({ length: 5 }, (_, index) => ({ id: String(index) })),
|
||||
scrollContainerRef: refs.scroller,
|
||||
virtualCancelBottomIntent: bottomApi.cancel,
|
||||
virtualizerOwnsPrependAnchoring: true,
|
||||
virtualScrollToMessage: () => true,
|
||||
});
|
||||
React.useLayoutEffect(() => {
|
||||
if (didRun.current) return;
|
||||
didRun.current = true;
|
||||
bottomApi.settle();
|
||||
refs.targetResult.current = anchored.scrollToMessage("selected");
|
||||
}, [anchored.scrollToMessage, bottomApi.settle, refs.targetResult]);
|
||||
return null;
|
||||
}
|
||||
|
||||
test("channel change attaches pinned-center observers after refs mount", async () => {
|
||||
const refs = {
|
||||
container: { current: null },
|
||||
@@ -272,3 +311,79 @@ test("channel change attaches pinned-center observers after refs mount", async (
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
test("mounted virtual target retires bottom intent before direct centering", async () => {
|
||||
const resizeObservers = [];
|
||||
globalThis.ResizeObserver = class {
|
||||
constructor(callback) {
|
||||
this.callback = callback;
|
||||
this.targets = [];
|
||||
resizeObservers.push(this);
|
||||
}
|
||||
disconnect() {}
|
||||
observe(target) {
|
||||
this.targets.push(target);
|
||||
}
|
||||
};
|
||||
|
||||
const content = document.createElement("div");
|
||||
const scroller = document.createElement("div");
|
||||
const host = document.createElement("div");
|
||||
scroller.appendChild(content);
|
||||
host.appendChild(scroller);
|
||||
scroller.clientHeight = 400;
|
||||
scroller.scrollHeight = 1_000;
|
||||
scroller.scrollTop = 0;
|
||||
scroller.getBoundingClientRect = () => ({ bottom: 400, top: 0 });
|
||||
const targetContentTop = 250;
|
||||
const row = {
|
||||
getBoundingClientRect: () => ({
|
||||
bottom: targetContentTop - scroller.scrollTop + 40,
|
||||
height: 40,
|
||||
top: targetContentTop - scroller.scrollTop,
|
||||
}),
|
||||
};
|
||||
scroller.querySelector = () => row;
|
||||
scroller.querySelectorAll = () => [];
|
||||
scroller.scrollTo = ({ top }) => {
|
||||
scroller.scrollTop = top;
|
||||
};
|
||||
|
||||
const bottomWrites = [];
|
||||
const refs = {
|
||||
content: { current: content },
|
||||
host: { current: host },
|
||||
itemsLength: { current: 5 },
|
||||
list: {
|
||||
current: {
|
||||
scrollToIndex: (index, options) =>
|
||||
bottomWrites.push({ index, options }),
|
||||
},
|
||||
},
|
||||
scroller: { current: scroller },
|
||||
targetResult: { current: null },
|
||||
};
|
||||
const root = createRoot(document.createElement("div"));
|
||||
await act(async () => {
|
||||
root.render(React.createElement(VirtualTargetHarness, { refs }));
|
||||
});
|
||||
|
||||
assert.deepEqual(bottomWrites, [{ index: 4, options: { align: "end" } }]);
|
||||
assert.equal(refs.targetResult.current, true);
|
||||
assert.equal(row.getBoundingClientRect().top, 180);
|
||||
const bottomGeometryObserver = resizeObservers.find(
|
||||
(observer) =>
|
||||
observer.targets.includes(content) && observer.targets.includes(scroller),
|
||||
);
|
||||
assert.ok(bottomGeometryObserver);
|
||||
bottomGeometryObserver.callback();
|
||||
await act(async () => new Promise((resolve) => setTimeout(resolve, 0)));
|
||||
|
||||
assert.equal(
|
||||
row.getBoundingClientRect().top,
|
||||
180,
|
||||
"target remains centered after later virtual geometry activity",
|
||||
);
|
||||
assert.equal(bottomWrites.length, 1, "geometry cannot re-pin to bottom");
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
@@ -109,6 +109,7 @@ type UseAnchoredScrollOptions = {
|
||||
/** Keeps a targeted message centered until the user deliberately scrolls. */
|
||||
pinTargetCentered?: boolean;
|
||||
onTargetReached?: (messageId: string) => void;
|
||||
virtualCancelBottomIntent?: () => void;
|
||||
virtualScrollToMessage?: (
|
||||
messageId: string,
|
||||
options?: { behavior?: ScrollBehavior },
|
||||
@@ -229,6 +230,7 @@ export function useAnchoredScroll({
|
||||
highlightTargetMessage = true,
|
||||
pinTargetCentered = false,
|
||||
onTargetReached,
|
||||
virtualCancelBottomIntent,
|
||||
virtualScrollToMessage,
|
||||
virtualScrollToBottom,
|
||||
virtualSettleAtBottom,
|
||||
@@ -439,6 +441,10 @@ export function useAnchoredScroll({
|
||||
`[data-message-id="${messageId}"]`,
|
||||
);
|
||||
if (virtualizerOwnsPrependAnchoring && virtualScrollToMessage) {
|
||||
// Target navigation owns the viewport before any movement strategy is
|
||||
// chosen. The already-mounted fast path centers the DOM node directly
|
||||
// and would otherwise leave durable bottom intent armed.
|
||||
virtualCancelBottomIntent?.();
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
@@ -534,6 +540,7 @@ export function useAnchoredScroll({
|
||||
highlightMessage,
|
||||
pinTargetCentered,
|
||||
scrollContainerRef,
|
||||
virtualCancelBottomIntent,
|
||||
virtualizerOwnsPrependAnchoring,
|
||||
writePinnedCenterScroll,
|
||||
virtualScrollToMessage,
|
||||
@@ -607,6 +614,11 @@ export function useAnchoredScroll({
|
||||
// to the requested target message, or to the bottom by default.
|
||||
if (!hasInitializedRef.current) {
|
||||
if (isLoading) return;
|
||||
// The virtualized list owns the actual scroll node. Its API registers in
|
||||
// a child layout effect, after this parent hook's first pass; treating
|
||||
// that API-less pass as initialized writes to the inert outer wrapper
|
||||
// and permanently consumes the channel's initial bottom pin.
|
||||
if (virtualizerOwnsPrependAnchoring && !virtualScrollToBottom) return;
|
||||
// Establish the initial position before the browser paints. The follow-up
|
||||
// frame is a settling pass for content whose measurements land with the
|
||||
// commit (fonts, deferred rows, media), not the first bottom pin. Keeping
|
||||
|
||||
@@ -4,13 +4,13 @@ import * as React from "react";
|
||||
* Holds an older-history prepend out of the rendered timeline until the
|
||||
* scroller is genuinely at rest, then admits it atomically.
|
||||
*
|
||||
* Why: every prepend-compensation mechanism in this design — Virtua's shift
|
||||
* correction, the pre-paint scrollBy, the semantic-anchor watcher — is a
|
||||
* scrollTop write. On macOS WKWebView those writes can be dropped or
|
||||
* overridden while trackpad momentum owns the committed offset, so a page
|
||||
* commit that lands mid-fling displaces the viewport by the full prepended
|
||||
* height with no reliable way to correct it. Committing only at rest keeps
|
||||
* all three writers operating in the regime where they are exact.
|
||||
* Why: Virtua reconciles an active prepend by correcting scrollTop as the
|
||||
* inserted rows are measured. On macOS WKWebView those corrections can be
|
||||
* dropped or overridden while trackpad momentum owns the committed offset, so
|
||||
* a page admitted mid-fling can displace the viewport by the full prepended
|
||||
* height. Admitting only at rest gives Virtua a stable geometry window in
|
||||
* which to complete that reconciliation exactly; subsequent reader wheel input
|
||||
* then retires it.
|
||||
*
|
||||
* The fetched store stays authoritative and fetches still start immediately;
|
||||
* this hook only delays when the fetched page joins the rendered snapshot.
|
||||
|
||||
@@ -15,6 +15,9 @@ export function useUpwardPaginationWheel(
|
||||
if (!(scroller instanceof HTMLDivElement)) return;
|
||||
let releaseTimer: number | null = null;
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
// Ctrl+wheel belongs to browser zoom. It must not retire bottom intent or
|
||||
// arm upward-pagination momentum because it does not move the reader.
|
||||
if (event.ctrlKey) return;
|
||||
onWheel();
|
||||
if (event.deltaY >= 0) {
|
||||
clear();
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
class EventTargetShim {
|
||||
listeners = new Map();
|
||||
addEventListener(type, listener) {
|
||||
this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]);
|
||||
}
|
||||
removeEventListener(type, listener) {
|
||||
this.listeners.set(
|
||||
type,
|
||||
(this.listeners.get(type) ?? []).filter(
|
||||
(current) => current !== listener,
|
||||
),
|
||||
);
|
||||
}
|
||||
dispatchEvent(event) {
|
||||
for (const listener of this.listeners.get(event.type) ?? [])
|
||||
listener(event);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class ElementShim extends EventTargetShim {
|
||||
constructor(firstElementChild = null) {
|
||||
super();
|
||||
this.firstElementChild = firstElementChild;
|
||||
this.children = [];
|
||||
this.childNodes = [];
|
||||
this.isContentEditable = false;
|
||||
this.nodeName = "DIV";
|
||||
this.tagName = "DIV";
|
||||
this.nodeType = 1;
|
||||
this.namespaceURI = "http://www.w3.org/1999/xhtml";
|
||||
}
|
||||
get ownerDocument() {
|
||||
return globalThis.document;
|
||||
}
|
||||
appendChild(child) {
|
||||
this.children.push(child);
|
||||
this.childNodes.push(child);
|
||||
return child;
|
||||
}
|
||||
removeChild(child) {
|
||||
this.children = this.children.filter((current) => current !== child);
|
||||
this.childNodes = this.childNodes.filter((current) => current !== child);
|
||||
return child;
|
||||
}
|
||||
insertBefore(child, reference) {
|
||||
const index = this.children.indexOf(reference);
|
||||
if (index < 0) return this.appendChild(child);
|
||||
this.children.splice(index, 0, child);
|
||||
this.childNodes.splice(index, 0, child);
|
||||
return child;
|
||||
}
|
||||
closest() {
|
||||
return null;
|
||||
}
|
||||
contains(target) {
|
||||
return this === target || this.firstElementChild?.contains(target) === true;
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.document = {
|
||||
addEventListener() {},
|
||||
createElement: () => new ElementShim(),
|
||||
get defaultView() {
|
||||
return globalThis.window;
|
||||
},
|
||||
nodeType: 9,
|
||||
removeEventListener() {},
|
||||
};
|
||||
|
||||
const animationFrames = new Map();
|
||||
let nextAnimationFrameId = 1;
|
||||
globalThis.requestAnimationFrame = (callback) => {
|
||||
const id = nextAnimationFrameId++;
|
||||
animationFrames.set(id, callback);
|
||||
return id;
|
||||
};
|
||||
globalThis.cancelAnimationFrame = (id) => animationFrames.delete(id);
|
||||
globalThis.HTMLElement = ElementShim;
|
||||
globalThis.HTMLDivElement = ElementShim;
|
||||
globalThis.Node = ElementShim;
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
process.env.IS_REACT_ACT_ENVIRONMENT = "true";
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: new EventTargetShim(),
|
||||
});
|
||||
globalThis.window.HTMLIFrameElement = ElementShim;
|
||||
|
||||
const resizeObservers = [];
|
||||
globalThis.ResizeObserver = class {
|
||||
constructor(callback) {
|
||||
this.callback = callback;
|
||||
resizeObservers.push(this);
|
||||
}
|
||||
disconnect() {}
|
||||
observe(target) {
|
||||
this.targets = [...(this.targets ?? []), target];
|
||||
}
|
||||
};
|
||||
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { useVirtualizedBottomSettle } from "./useVirtualizedBottomSettle.ts";
|
||||
|
||||
function flushAnimationFrames() {
|
||||
const callbacks = [...animationFrames.values()];
|
||||
animationFrames.clear();
|
||||
for (const callback of callbacks) callback(performance.now());
|
||||
}
|
||||
|
||||
function Harness({ apiRef, hostRef, itemsLengthRef, listRef }) {
|
||||
apiRef.current = useVirtualizedBottomSettle(hostRef, listRef, itemsLengthRef);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function mountHarness() {
|
||||
animationFrames.clear();
|
||||
resizeObservers.length = 0;
|
||||
const content = new ElementShim();
|
||||
const scroller = new ElementShim(content);
|
||||
const host = new ElementShim(scroller);
|
||||
const writes = [];
|
||||
const refs = {
|
||||
api: { current: null },
|
||||
host: { current: host },
|
||||
itemsLength: { current: 5 },
|
||||
list: {
|
||||
current: {
|
||||
scrollToIndex(index, options) {
|
||||
writes.push({ index, options });
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const root = createRoot(new ElementShim());
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(Harness, {
|
||||
apiRef: refs.api,
|
||||
hostRef: refs.host,
|
||||
itemsLengthRef: refs.itemsLength,
|
||||
listRef: refs.list,
|
||||
}),
|
||||
);
|
||||
});
|
||||
return { content, refs, root, scroller, writes };
|
||||
}
|
||||
|
||||
test("bottom intent follows arbitrarily late virtual geometry changes", async () => {
|
||||
const { content, refs, root, scroller, writes } = await mountHarness();
|
||||
refs.api.current.settle();
|
||||
assert.deepEqual(writes, [{ index: 4, options: { align: "end" } }]);
|
||||
|
||||
const geometryObserver = resizeObservers.find((observer) =>
|
||||
observer.targets?.includes(content),
|
||||
);
|
||||
assert.ok(geometryObserver);
|
||||
geometryObserver.callback();
|
||||
flushAnimationFrames();
|
||||
assert.equal(writes.length, 2);
|
||||
|
||||
// There is no deadline: another geometry change much later still re-pins.
|
||||
geometryObserver.callback();
|
||||
flushAnimationFrames();
|
||||
assert.equal(writes.length, 3);
|
||||
|
||||
// Viewport changes also move the physical floor without resizing content.
|
||||
assert.ok(geometryObserver.targets.includes(scroller));
|
||||
geometryObserver.callback();
|
||||
flushAnimationFrames();
|
||||
assert.equal(writes.length, 4);
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
for (const eventType of ["pointerdown", "touchmove", "wheel", "keydown"]) {
|
||||
test(`${eventType} transfers ownership away from bottom intent`, async () => {
|
||||
const { content, refs, root, scroller, writes } = await mountHarness();
|
||||
refs.api.current.settle();
|
||||
const target = eventType === "keydown" ? window : scroller;
|
||||
target.dispatchEvent({
|
||||
altKey: false,
|
||||
ctrlKey: false,
|
||||
key: eventType === "keydown" ? "PageUp" : undefined,
|
||||
metaKey: false,
|
||||
target:
|
||||
eventType === "keydown"
|
||||
? scroller
|
||||
: eventType === "pointerdown"
|
||||
? scroller
|
||||
: undefined,
|
||||
type: eventType,
|
||||
});
|
||||
|
||||
resizeObservers
|
||||
.find((observer) => observer.targets?.includes(content))
|
||||
.callback();
|
||||
flushAnimationFrames();
|
||||
assert.equal(writes.length, 1);
|
||||
await act(async () => root.unmount());
|
||||
assert.equal(
|
||||
scroller.listeners.get(eventType)?.length ?? 0,
|
||||
0,
|
||||
`${eventType} listener is removed on unmount`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test("descendant interaction and outside navigation preserve bottom intent", async () => {
|
||||
const { content, refs, root, scroller, writes } = await mountHarness();
|
||||
refs.api.current.settle();
|
||||
const rowControl = new ElementShim();
|
||||
scroller.firstElementChild.firstElementChild = rowControl;
|
||||
|
||||
scroller.dispatchEvent({ target: rowControl, type: "pointerdown" });
|
||||
window.dispatchEvent({
|
||||
altKey: false,
|
||||
ctrlKey: false,
|
||||
key: "PageUp",
|
||||
metaKey: false,
|
||||
target: new ElementShim(),
|
||||
type: "keydown",
|
||||
});
|
||||
resizeObservers
|
||||
.find((observer) => observer.targets?.includes(content))
|
||||
.callback();
|
||||
flushAnimationFrames();
|
||||
|
||||
assert.equal(writes.length, 2);
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
test("Ctrl+wheel zoom preserves bottom intent through geometry reflow", async () => {
|
||||
const { content, refs, root, scroller, writes } = await mountHarness();
|
||||
refs.api.current.settle();
|
||||
|
||||
scroller.dispatchEvent({ ctrlKey: true, deltaY: -100, type: "wheel" });
|
||||
resizeObservers
|
||||
.find((observer) => observer.targets?.includes(content))
|
||||
.callback();
|
||||
flushAnimationFrames();
|
||||
|
||||
assert.equal(writes.length, 2);
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
test("typing and editable navigation keys preserve bottom intent", async () => {
|
||||
const { content, refs, root, writes } = await mountHarness();
|
||||
refs.api.current.settle();
|
||||
|
||||
window.dispatchEvent({
|
||||
altKey: false,
|
||||
ctrlKey: false,
|
||||
key: "a",
|
||||
metaKey: false,
|
||||
target: null,
|
||||
type: "keydown",
|
||||
});
|
||||
const editable = new ElementShim();
|
||||
editable.isContentEditable = true;
|
||||
window.dispatchEvent({
|
||||
altKey: false,
|
||||
ctrlKey: false,
|
||||
key: "ArrowUp",
|
||||
metaKey: false,
|
||||
target: editable,
|
||||
type: "keydown",
|
||||
});
|
||||
|
||||
resizeObservers
|
||||
.find((observer) => observer.targets?.includes(content))
|
||||
.callback();
|
||||
flushAnimationFrames();
|
||||
assert.equal(writes.length, 2);
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
@@ -1,67 +1,129 @@
|
||||
import * as React from "react";
|
||||
import type { VListHandle } from "virtua";
|
||||
|
||||
const BOTTOM_EPSILON_PX = 1;
|
||||
const SETTLE_DEADLINE_MS = 250;
|
||||
const SCROLL_INTENT_KEYS = new Set([
|
||||
"ArrowDown",
|
||||
"ArrowUp",
|
||||
"End",
|
||||
"Home",
|
||||
"PageDown",
|
||||
"PageUp",
|
||||
" ",
|
||||
]);
|
||||
|
||||
function isEditableKeyboardTarget(target: EventTarget | null) {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
if (target.isContentEditable) return true;
|
||||
return (
|
||||
target.closest("input, textarea, select, [contenteditable='true']") !== null
|
||||
);
|
||||
}
|
||||
|
||||
export function useVirtualizedBottomSettle(
|
||||
hostRef: React.RefObject<HTMLDivElement | null>,
|
||||
listRef: React.RefObject<VListHandle | null>,
|
||||
itemsLengthRef: React.RefObject<number>,
|
||||
) {
|
||||
const bottomIntentRef = React.useRef(false);
|
||||
const frameRef = React.useRef<number | null>(null);
|
||||
const cancel = React.useCallback(() => {
|
||||
|
||||
const cancelFrame = React.useCallback(() => {
|
||||
if (frameRef.current !== null) {
|
||||
cancelAnimationFrame(frameRef.current);
|
||||
frameRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const cancel = React.useCallback(() => {
|
||||
bottomIntentRef.current = false;
|
||||
cancelFrame();
|
||||
}, [cancelFrame]);
|
||||
|
||||
const pinToBottom = React.useCallback(() => {
|
||||
if (!bottomIntentRef.current) return;
|
||||
const scroller = hostRef.current?.firstElementChild;
|
||||
const lastIndex = itemsLengthRef.current - 1;
|
||||
if (!(scroller instanceof HTMLDivElement) || lastIndex < 0) return;
|
||||
listRef.current?.scrollToIndex(lastIndex, { align: "end" });
|
||||
}, [hostRef, itemsLengthRef, listRef]);
|
||||
|
||||
const schedulePinToBottom = React.useCallback(() => {
|
||||
if (!bottomIntentRef.current || frameRef.current !== null) return;
|
||||
frameRef.current = requestAnimationFrame(() => {
|
||||
frameRef.current = null;
|
||||
pinToBottom();
|
||||
});
|
||||
}, [pinToBottom]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const scroller = hostRef.current?.firstElementChild;
|
||||
if (!(scroller instanceof HTMLDivElement)) return;
|
||||
const retire = () => cancel();
|
||||
scroller.addEventListener("pointerdown", retire, { passive: true });
|
||||
scroller.addEventListener("touchstart", retire, { passive: true });
|
||||
scroller.addEventListener("wheel", retire, { passive: true });
|
||||
window.addEventListener("keydown", retire, true);
|
||||
const retireForPointer = (event: PointerEvent) => {
|
||||
// A descendant pointerdown is ordinary row interaction (link, reaction,
|
||||
// thread action), not evidence that the reader took scroll ownership.
|
||||
// Direct scroller hits cover scrollbar/background drag initiation.
|
||||
if (event.target === scroller) cancel();
|
||||
};
|
||||
const retireForWheel = (event: WheelEvent) => {
|
||||
// Ctrl+wheel is browser zoom, not reader navigation. Keep bottom intent
|
||||
// armed so the resulting viewport/content reflow can settle at the new
|
||||
// physical floor.
|
||||
if (!event.ctrlKey) cancel();
|
||||
};
|
||||
const retireForScrollKey = (event: KeyboardEvent) => {
|
||||
if (
|
||||
!event.altKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
event.target instanceof Node &&
|
||||
scroller.contains(event.target) &&
|
||||
!isEditableKeyboardTarget(event.target) &&
|
||||
SCROLL_INTENT_KEYS.has(event.key)
|
||||
) {
|
||||
cancel();
|
||||
}
|
||||
};
|
||||
scroller.addEventListener("pointerdown", retireForPointer, {
|
||||
passive: true,
|
||||
});
|
||||
scroller.addEventListener("touchmove", retire, { passive: true });
|
||||
scroller.addEventListener("wheel", retireForWheel, { passive: true });
|
||||
window.addEventListener("keydown", retireForScrollKey, true);
|
||||
return () => {
|
||||
scroller.removeEventListener("pointerdown", retire);
|
||||
scroller.removeEventListener("touchstart", retire);
|
||||
scroller.removeEventListener("wheel", retire);
|
||||
window.removeEventListener("keydown", retire, true);
|
||||
scroller.removeEventListener("pointerdown", retireForPointer);
|
||||
scroller.removeEventListener("touchmove", retire);
|
||||
scroller.removeEventListener("wheel", retireForWheel);
|
||||
window.removeEventListener("keydown", retireForScrollKey, true);
|
||||
};
|
||||
}, [cancel, hostRef]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const scroller = hostRef.current?.firstElementChild;
|
||||
if (!(scroller instanceof HTMLDivElement)) return;
|
||||
const content = scroller.firstElementChild;
|
||||
if (
|
||||
!(content instanceof HTMLElement) ||
|
||||
typeof ResizeObserver === "undefined"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Virtua updates this inner element's extent whenever measured row geometry
|
||||
// changes. Bottom intent therefore follows the physical floor for as long
|
||||
// as it remains active, rather than guessing that layout is done after an
|
||||
// arbitrary timeout. Reader input and explicit target/prepend navigation
|
||||
// retire the intent through `cancel`.
|
||||
const observer = new ResizeObserver(schedulePinToBottom);
|
||||
observer.observe(content);
|
||||
observer.observe(scroller);
|
||||
return () => observer.disconnect();
|
||||
}, [hostRef, schedulePinToBottom]);
|
||||
|
||||
const settle = React.useCallback(() => {
|
||||
cancel();
|
||||
const deadline = performance.now() + SETTLE_DEADLINE_MS;
|
||||
let settledFrames = 0;
|
||||
let previousHeight = -1;
|
||||
const next = () => {
|
||||
const scroller = hostRef.current?.firstElementChild;
|
||||
const lastIndex = itemsLengthRef.current - 1;
|
||||
if (!(scroller instanceof HTMLDivElement) || lastIndex < 0) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
listRef.current?.scrollToIndex(lastIndex, { align: "end" });
|
||||
const atBottom =
|
||||
scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop <=
|
||||
BOTTOM_EPSILON_PX;
|
||||
settledFrames =
|
||||
atBottom && scroller.scrollHeight === previousHeight
|
||||
? settledFrames + 1
|
||||
: 0;
|
||||
previousHeight = scroller.scrollHeight;
|
||||
if (settledFrames >= 2 || performance.now() >= deadline) {
|
||||
frameRef.current = null;
|
||||
return;
|
||||
}
|
||||
frameRef.current = requestAnimationFrame(next);
|
||||
};
|
||||
next();
|
||||
}, [cancel, hostRef, itemsLengthRef, listRef]);
|
||||
bottomIntentRef.current = true;
|
||||
cancelFrame();
|
||||
pinToBottom();
|
||||
}, [cancelFrame, pinToBottom]);
|
||||
|
||||
React.useEffect(() => cancel, [cancel]);
|
||||
return { cancel, settle };
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
const patch = await readFile(
|
||||
new URL("../../../../../patches/virtua@0.49.3.patch", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
test("reader wheel retires Virtua shift mode without publishing scroll end", () => {
|
||||
// Virtua 0.49.3 has no public transition for leaving SCROLL_BY_SHIFT.
|
||||
// Keep the CJS and ESM patch paths symmetric and deliberately narrower than
|
||||
// ACTION_SCROLL_END (2), which also idles direction, clears frozen range,
|
||||
// flushes pending jumps, and emits UPDATE_SCROLL_END_EVENT.
|
||||
const addedActionBodies = [
|
||||
...patch.matchAll(/\+\s+case 9:\n((?:\+.*\n)+?)(?=\s*})/g),
|
||||
].map(([, body]) =>
|
||||
body
|
||||
.split("\n")
|
||||
.map((line) => line.replace(/^\+\s*/, "").trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
assert.deepEqual(addedActionBodies, [["I = 0;"], ["w = 0;"]]);
|
||||
assert.match(patch, /\+\s+e\.q\(9\);/);
|
||||
assert.match(patch, /\+\s+e\.B\(9\);/);
|
||||
assert.doesNotMatch(patch, /\+\s+e\.(?:q|B)\(2\);/);
|
||||
});
|
||||
@@ -68,6 +68,61 @@ async function getMessagePosition(
|
||||
}, messageId);
|
||||
}
|
||||
|
||||
test("channel switch settles at the newest message after virtualized rows measure", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page);
|
||||
await page.goto("/");
|
||||
await page.waitForFunction(
|
||||
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
|
||||
);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const base = Math.floor(Date.now() / 1000);
|
||||
for (let index = 0; index < 80; index += 1) {
|
||||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "general",
|
||||
content: `switch-bottom ${index} ${"variable-height ".repeat(
|
||||
index % 7,
|
||||
)}`,
|
||||
createdAt: base + index,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Open another channel first so this exercises a real channel switch, where
|
||||
// the virtualizer API is temporarily null while the keyed list remounts.
|
||||
await page.getByTestId("channel-random").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("random");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
|
||||
const timeline = page.getByTestId("message-timeline");
|
||||
await expect(timeline).toContainText("switch-bottom 79");
|
||||
// Reflow a rendered row well after the removed 250ms settle deadline. The
|
||||
// geometry-driven bottom intent must still chase the new physical floor.
|
||||
await timeline.evaluate((element) => {
|
||||
window.setTimeout(() => {
|
||||
const row = element.querySelector<HTMLElement>("[data-message-id]");
|
||||
if (row) {
|
||||
row.style.minHeight = `${row.getBoundingClientRect().height + 240}px`;
|
||||
}
|
||||
element.dataset.delayedBottomReflow = "complete";
|
||||
}, 600);
|
||||
});
|
||||
await expect(timeline).toHaveAttribute(
|
||||
"data-delayed-bottom-reflow",
|
||||
"complete",
|
||||
);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const metrics = await getTimelineMetrics(page);
|
||||
return metrics.scrollHeight - metrics.clientHeight - metrics.scrollTop;
|
||||
})
|
||||
.toBeLessThanOrEqual(1);
|
||||
await expect(page.getByTestId("message-scroll-to-latest")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("first channel load paints the first window without waiting for the row-floor top-up", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -168,6 +223,7 @@ test("preserves user scroll while older channel history loads", async ({
|
||||
const scrollToTop = async () =>
|
||||
timeline.evaluate((element) => {
|
||||
const container = element as HTMLDivElement;
|
||||
container.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 }));
|
||||
container.scrollTop = 0;
|
||||
container.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
});
|
||||
@@ -454,11 +510,20 @@ test("does not teleport upward when user abandons fetch by jumping to bottom", a
|
||||
)
|
||||
.toBe("resolved");
|
||||
|
||||
const afterPrepend = await getTimelineMetrics(page);
|
||||
// (a) Geometry: timeline still pinned to bottom.
|
||||
expect(
|
||||
afterPrepend.scrollTop + afterPrepend.clientHeight,
|
||||
).toBeGreaterThanOrEqual(afterPrepend.scrollHeight - 2);
|
||||
// (a) Geometry: timeline settles back to bottom. The durable bottom owner
|
||||
// batches ResizeObserver-driven geometry correction into requestAnimationFrame,
|
||||
// so scrollHeight growth and the physical-floor write need not share a turn.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const metrics = await getTimelineMetrics(page);
|
||||
return (
|
||||
metrics.scrollTop + metrics.clientHeight >= metrics.scrollHeight - 2
|
||||
);
|
||||
},
|
||||
{ timeout: 2_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
// (b) DOM: the last rendered [data-message-id] sits within 2px of the
|
||||
// timeline's bottom edge. This catches a class of bugs where the geometry
|
||||
@@ -1641,6 +1706,7 @@ test("channel intro stays hidden while paginating past the timeline cap", async
|
||||
const scrollToTop = async () =>
|
||||
timeline.evaluate((element) => {
|
||||
const container = element as HTMLDivElement;
|
||||
container.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 }));
|
||||
container.scrollTop = 0;
|
||||
container.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
});
|
||||
|
||||
@@ -634,6 +634,9 @@ test("does not shift the timeline when the composer grows", async ({
|
||||
await page.waitForTimeout(400);
|
||||
await page.getByTestId("message-timeline").evaluate((element) => {
|
||||
const timeline = element as HTMLDivElement;
|
||||
// The raw position assignment sets up detached history, while wheel is the
|
||||
// same ownership signal a real reader produces before composer reflow.
|
||||
timeline.dispatchEvent(new WheelEvent("wheel", { deltaY: -100 }));
|
||||
timeline.scrollTop = 0;
|
||||
timeline.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
|
||||
@@ -481,6 +481,7 @@ test("timeline prepend plus late row reflow keeps the reading row stable", async
|
||||
await page.waitForTimeout(100);
|
||||
await timeline.evaluate((element) => {
|
||||
const scroller = element as HTMLDivElement;
|
||||
scroller.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 }));
|
||||
scroller.scrollTop = 150;
|
||||
scroller.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
});
|
||||
|
||||
@@ -297,8 +297,25 @@ test.describe("list virtualization", () => {
|
||||
});
|
||||
const committedAnchor = await sampleVisibleAnchor(before.id);
|
||||
const motion = await timeline.evaluate(
|
||||
async (scroller, { anchorId, anchorTop, oldHeight }) => {
|
||||
async (scroller, { anchorId, anchorTop, oldHeight, testCtrlWheel }) => {
|
||||
const s = scroller as HTMLElement;
|
||||
let ctrlWheelDispatched = false;
|
||||
const mutationObserver = testCtrlWheel
|
||||
? new MutationObserver(() => {
|
||||
if (ctrlWheelDispatched) return;
|
||||
ctrlWheelDispatched = true;
|
||||
// Ctrl+wheel is browser zoom, not reader scroll intent. Fire it
|
||||
// synchronously with the prepend DOM commit, before the
|
||||
// ResizeObserver measurement batch reconciles estimated rows.
|
||||
s.dispatchEvent(
|
||||
new WheelEvent("wheel", { ctrlKey: true, deltaY: -100 }),
|
||||
);
|
||||
})
|
||||
: null;
|
||||
mutationObserver?.observe(s.firstElementChild ?? s, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
let maxDrift = 0;
|
||||
let sawPrepend = false;
|
||||
let sawAnchorAfterPrepend = false;
|
||||
@@ -326,15 +343,20 @@ test.describe("list virtualization", () => {
|
||||
if (sawAnchorAfterPrepend && stableFrames >= 8) break;
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
}
|
||||
return { maxDrift, sawPrepend };
|
||||
mutationObserver?.disconnect();
|
||||
return { ctrlWheelDispatched, maxDrift, sawPrepend };
|
||||
},
|
||||
{
|
||||
anchorId: committedAnchor.id,
|
||||
anchorTop: committedAnchor.top,
|
||||
oldHeight: before.scrollHeight,
|
||||
// Exercise browser-zoom input once while a real prepend transaction
|
||||
// still owns its measurement reconciliation.
|
||||
testCtrlWheel: pageIndex === 0,
|
||||
},
|
||||
);
|
||||
expect(motion.sawPrepend).toBe(true);
|
||||
if (pageIndex === 0) expect(motion.ctrlWheelDispatched).toBe(true);
|
||||
expect(motion.maxDrift).toBeLessThan(5);
|
||||
|
||||
await expect
|
||||
@@ -357,11 +379,10 @@ test.describe("list virtualization", () => {
|
||||
await timeline.evaluate((element) => element.clientHeight),
|
||||
);
|
||||
|
||||
// Leave the boundary with real downward wheel input while this prepend's
|
||||
// three-second semantic-anchor watcher is still alive. The watcher belongs
|
||||
// only to the completed prepend: it must not reinterpret this deliberate
|
||||
// reader movement as row drift and pull the viewport back toward its stale
|
||||
// baseline before the next upward load.
|
||||
// Leave the boundary with real downward wheel input after this prepend.
|
||||
// That reader intent retires Virtua's active prepend reconciliation, so
|
||||
// later row measurements must not pull the viewport back toward the
|
||||
// completed prepend before the next upward load.
|
||||
const exitTracePromise = timeline.evaluate(async (scroller) => {
|
||||
const s = scroller as HTMLElement;
|
||||
const startScrollTop = s.scrollTop;
|
||||
@@ -667,6 +688,10 @@ test("offscreen rich-row resize preserves the viewport-center anchor", async ({
|
||||
|
||||
const result = await timeline.evaluate(async (element) => {
|
||||
const scroller = element as HTMLDivElement;
|
||||
// Retire bottom-follow intent the same way real reader input does before
|
||||
// moving into detached history. A raw scrollTop assignment alone is not
|
||||
// user intent and would correctly leave bottom following armed.
|
||||
scroller.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 }));
|
||||
scroller.scrollTop = scroller.scrollHeight / 2;
|
||||
scroller.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
|
||||
+62
-15
@@ -1,5 +1,5 @@
|
||||
diff --git a/lib/index.cjs b/lib/index.cjs
|
||||
index e02dfd0b3db60faff2cc705d7ce0aa51cb958f5a..c3bbc7f4d8dd396f5329a40a2701f4f752e3c5cc 100644
|
||||
index e02dfd0b3db60faff2cc705d7ce0aa51cb958f5a..b12677a356f748ebccf6e16cb781efe1315bb55b 100644
|
||||
--- a/lib/index.cjs
|
||||
+++ b/lib/index.cjs
|
||||
@@ -35,10 +35,18 @@ const r = null, {min: n, max: s, abs: i, floor: l} = Math, c = (e, t, o) => n(o,
|
||||
@@ -39,11 +39,39 @@ index e02dfd0b3db60faff2cc705d7ce0aa51cb958f5a..c3bbc7f4d8dd396f5329a40a2701f4f7
|
||||
|
||||
case 5:
|
||||
- t[1] ? (L(w(M, t[0], !0)), I = 2, l = 1) : (w(M, t[0]), l = 1);
|
||||
+ t[1] ? (L(w(M, t[0], !0, t[2])), I = 2, l = 1) : (w(M, t[0], !1, t[2]), l = 1);
|
||||
+ t[1] ? (L(w(M, t[0], !0, t[2])), I = 2, l = 1) : (w(M, t[0], !1, t[2]), I = 0, l = 1);
|
||||
break;
|
||||
|
||||
case 6:
|
||||
@@ -519,11 +527,11 @@ const r = null, {min: n, max: s, abs: i, floor: l} = Math, c = (e, t, o) => n(o,
|
||||
@@ -160,6 +168,10 @@ const r = null, {min: n, max: s, abs: i, floor: l} = Math, c = (e, t, o) => n(o,
|
||||
|
||||
case 8:
|
||||
k = W(t, t + a), l = 1;
|
||||
+ break;
|
||||
+
|
||||
+ case 9:
|
||||
+ I = 0;
|
||||
}
|
||||
l && (d = 1 + (2147483647 & d), o && z && ($ += z, z = 0), O.forEach(([e, t]) => {
|
||||
l & e && t(n);
|
||||
@@ -186,6 +198,7 @@ const r = null, {min: n, max: s, abs: i, floor: l} = Math, c = (e, t, o) => n(o,
|
||||
c = l(), d && (a = !0), i && e.q(6, i()), e.q(1, n()), h();
|
||||
}, p = t => {
|
||||
if (f || !e.M() || t.ctrlKey) return;
|
||||
+ e.q(9);
|
||||
const r = l() - c;
|
||||
150 > r && 50 < r && (o ? t.deltaX : t.deltaY) && (f = !0);
|
||||
}, v = () => {
|
||||
@@ -266,7 +279,7 @@ const r = null, {min: n, max: s, abs: i, floor: l} = Math, c = (e, t, o) => n(o,
|
||||
e[l] = t;
|
||||
});
|
||||
}
|
||||
- f[i] = R(e.C() + t, s), o && u();
|
||||
+ f[i] = R((o ? R(f[i], s) : e.C()) + t, s), o && u();
|
||||
}), n[1](!0);
|
||||
},
|
||||
v() {
|
||||
@@ -519,11 +532,11 @@ const r = null, {min: n, max: s, abs: i, floor: l} = Math, c = (e, t, o) => n(o,
|
||||
})(e);
|
||||
return [ e => t[e], t.length ];
|
||||
}, [ e, o ]), j = /*#__PURE__*/ t.forwardRef(({children: r, data: n, bufferSize: s, itemSize: i, shift: l, horizontal: c, keepMounted: d, cache: a, startMargin: h = 0, ssrCount: g, as: p = "div", item: v = "div", scrollRef: _, onScroll: w, onScrollEnd: S}, m) => {
|
||||
@@ -59,7 +87,7 @@ index e02dfd0b3db60faff2cc705d7ce0aa51cb958f5a..c3bbc7f4d8dd396f5329a40a2701f4f7
|
||||
const o = $(t);
|
||||
return e.jsx(Y, {
|
||||
diff --git a/lib/index.js b/lib/index.js
|
||||
index 110ac3858a002a6cdb698da2b56350bc1bf609d2..81d239dad48d4453efd5ce7f8397555c66c99d56 100644
|
||||
index 110ac3858a002a6cdb698da2b56350bc1bf609d2..db83efc83fb3b6d7aa07d83b7463d6ed7746287e 100644
|
||||
--- a/lib/index.js
|
||||
+++ b/lib/index.js
|
||||
@@ -1,7 +1,7 @@
|
||||
@@ -102,25 +130,44 @@ index 110ac3858a002a6cdb698da2b56350bc1bf609d2..81d239dad48d4453efd5ce7f8397555c
|
||||
if (n = d(n, e.l - 1), b(e, n) <= t) {
|
||||
const r = y(e, o, n);
|
||||
return [ y(e, t, n, r), r ];
|
||||
@@ -124,7 +132,7 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o,
|
||||
if (!e.length) break;
|
||||
N(e.reduce((e, [t, o]) => {
|
||||
let n;
|
||||
- if (2 === w) n = !0; else if (I && 1 === w) n = t < I[0]; else {
|
||||
+ if (2 === w) n = J(t) < E(); else if (I && 1 === w) n = t < I[0]; else {
|
||||
const e = E(), o = J(t), r = A(t);
|
||||
n = 1 !== _ && 0 === w ? o + r <= e : o < e && o + r < e + l;
|
||||
}
|
||||
@@ -151,7 +159,7 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o,
|
||||
break;
|
||||
|
||||
case 5:
|
||||
- t[1] ? (N(x(M, t[0], !0)), w = 2, d = 1) : (x(M, t[0]), d = 1);
|
||||
+ t[1] ? (N(x(M, t[0], !0, t[2])), w = 2, d = 1) : (x(M, t[0], !1, t[2]), d = 1);
|
||||
+ t[1] ? (N(x(M, t[0], !0, t[2])), w = 2, d = 1) : (x(M, t[0], !1, t[2]), w = 0, d = 1);
|
||||
break;
|
||||
|
||||
case 6:
|
||||
@@ -523,11 +531,11 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o,
|
||||
@@ -164,6 +172,10 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o,
|
||||
|
||||
case 8:
|
||||
I = W(t, t + l), d = 1;
|
||||
+ break;
|
||||
+
|
||||
+ case 9:
|
||||
+ w = 0;
|
||||
}
|
||||
d && (i = 1 + (2147483647 & i), o && p && (g += p, p = 0), O.forEach(([e, t]) => {
|
||||
d & e && t(n);
|
||||
@@ -190,6 +202,7 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o,
|
||||
l = i(), d && (a = !0), s && e.B(6, s()), e.B(1, n()), h();
|
||||
}, p = t => {
|
||||
if (c || !e.M() || t.ctrlKey) return;
|
||||
+ e.B(9);
|
||||
const n = i() - l;
|
||||
150 > n && 50 < n && (o ? t.deltaX : t.deltaY) && (c = !0);
|
||||
}, v = () => {
|
||||
@@ -270,7 +283,7 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o,
|
||||
e[l] = t;
|
||||
});
|
||||
}
|
||||
- u[i] = W(e.C() + t, s), o && f();
|
||||
+ u[i] = W((o ? W(u[i], s) : e.C()) + t, s), o && f();
|
||||
}), r[1](!0);
|
||||
},
|
||||
v() {
|
||||
@@ -523,11 +536,11 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o,
|
||||
})(e);
|
||||
return [ e => o[e], o.length ];
|
||||
}, [ e, t ]), Z = /*#__PURE__*/ i(({children: t, data: o, bufferSize: r, itemSize: s, shift: i, horizontal: u, keepMounted: d, cache: a, startMargin: h = 0, ssrCount: g, as: p = "div", item: _ = "div", scrollRef: w, onScroll: S, onScrollEnd: $}, z) => {
|
||||
|
||||
Generated
+3
-3
@@ -9,7 +9,7 @@ overrides:
|
||||
|
||||
patchedDependencies:
|
||||
isomorphic-git: e9b414a60d4cf1d8aa18f7a779483984e821989967c235662566e94ef0238d3f
|
||||
virtua@0.49.3: acef47b2cfcb8bfd36668c30a16f918fe492eb07a1ddeafc8cc3a22a3efbf71b
|
||||
virtua@0.49.3: 63923c1f0c73f6fd487c788159fe2f1bd6930e5aeebe815cc511c1c4747294bc
|
||||
|
||||
importers:
|
||||
|
||||
@@ -232,7 +232,7 @@ importers:
|
||||
version: 2.1.0
|
||||
virtua:
|
||||
specifier: 0.49.3
|
||||
version: 0.49.3(patch_hash=acef47b2cfcb8bfd36668c30a16f918fe492eb07a1ddeafc8cc3a22a3efbf71b)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
version: 0.49.3(patch_hash=63923c1f0c73f6fd487c788159fe2f1bd6930e5aeebe815cc511c1c4747294bc)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
yaml:
|
||||
specifier: ^2.8.3
|
||||
version: 2.9.0
|
||||
@@ -6926,7 +6926,7 @@ snapshots:
|
||||
'@types/unist': 3.0.3
|
||||
vfile-message: 4.0.3
|
||||
|
||||
virtua@0.49.3(patch_hash=acef47b2cfcb8bfd36668c30a16f918fe492eb07a1ddeafc8cc3a22a3efbf71b)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
|
||||
virtua@0.49.3(patch_hash=63923c1f0c73f6fd487c788159fe2f1bd6930e5aeebe815cc511c1c4747294bc)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
|
||||
optionalDependencies:
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
|
||||
Reference in New Issue
Block a user