Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 14 additions & 8 deletions desktop/src/features/messages/ui/TimelineMessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -623,11 +623,8 @@ function VirtualizedTimelineRows({
if (!onVirtualizerApiChange) return;
const api: TimelineVirtualizerApi = {
scrollToBottom() {
retireTimelineSettle();
const lastIndex = itemsLengthRef.current - 1;
if (lastIndex >= 0) {
listRef.current?.scrollToIndex(lastIndex, { align: "end" });
}
retirePrependAnchor();
settleAtBottom();
},
settleAtBottom,
scrollToMessage(messageId) {
Expand All @@ -640,7 +637,12 @@ function VirtualizedTimelineRows({
};
onVirtualizerApiChange(api);
return () => onVirtualizerApiChange(null);
}, [onVirtualizerApiChange, retireTimelineSettle, settleAtBottom]);
}, [
onVirtualizerApiChange,
retirePrependAnchor,
retireTimelineSettle,
settleAtBottom,
]);

React.useLayoutEffect(() => {
const host = hostRef.current;
Expand Down Expand Up @@ -670,7 +672,12 @@ 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 ||
Expand All @@ -686,7 +693,6 @@ function VirtualizedTimelineRows({
},
[
armUpwardMomentum,
cancelBottomSettle,
capturePrependAnchor,
onAtBottomStateChange,
onStartReached,
Expand Down
5 changes: 5 additions & 0 deletions desktop/src/features/messages/ui/useAnchoredScroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,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
Expand Down
227 changes: 227 additions & 0 deletions desktop/src/features/messages/ui/useVirtualizedBottomSettle.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
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;
}
}

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.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", "touchstart", "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,
type: eventType,
});

resizeObservers
.find((observer) => observer.targets?.includes(content))
.callback();
flushAnimationFrames();
assert.equal(writes.length, 1);
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());
});
Loading
Loading