fix(desktop): single-owner anchored scroll for dynamic loading (#1115)

Signed-off-by: Tyler Longwell <tlongwell@squareup.com>
Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Tyler
2026-06-18 20:40:05 -04:00
committed by GitHub
parent 4d2eb52a6c
commit 047db4214e
25 changed files with 2849 additions and 841 deletions

View File

@@ -50,6 +50,7 @@ export default defineConfig({
"**/animated-avatar-screenshots.spec.ts",
"**/reminders-screenshots.spec.ts",
"**/virtualization-screenshots.spec.ts",
"**/scroll-history.spec.ts",
],
use: {
...devices["Desktop Chrome"],

View File

@@ -0,0 +1,23 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
timeout: 60_000,
retries: 0,
workers: 1,
reporter: [["list"]],
use: { baseURL: "http://127.0.0.1:4173" },
projects: [
{
name: "perf",
testMatch: ["**/*.perf.ts"],
use: { ...devices["Desktop Chrome"] },
},
],
webServer: {
command: "python3 -m http.server 4173 -d dist",
cwd: ".",
reuseExistingServer: true,
url: "http://127.0.0.1:4173",
},
});

View File

@@ -39,18 +39,46 @@ export function ChannelRouteScreen({
return cachedTarget ? [cachedTarget] : [];
});
// Reset spliced target events when the channel context changes (channel
// switch or entering/leaving a forum post). Tied to channel identity rather
// than the route target so clearing the `messageId` param mid-channel keeps
// the deep-linked row in view. Seeded with the mount key so the initial
// cache-seeded events survive first commit; only a genuine channel change
// clears them. Declared before the fetch effect so a channel switch clears
// stale events before the new target is fetched.
const previousResetKeyRef = React.useRef<string>(
`${channelId}::${selectedPostId ?? ""}`,
);
React.useEffect(() => {
const resetKey = `${channelId}::${selectedPostId ?? ""}`;
if (previousResetKeyRef.current === resetKey) return;
previousResetKeyRef.current = resetKey;
setTargetMessageEvents([]);
}, [channelId, selectedPostId]);
React.useEffect(() => {
let isCancelled = false;
// Don't wipe already-spliced target events just because the route target
// cleared (e.g. `onTargetReached` clears the `messageId` URL param once the
// row is centered). In a channel whose feed doesn't already contain the
// deep-linked message, the spliced event is the only copy — dropping it on
// param-clear blanks the timeline. Resetting on channel / forum-post change
// is handled by the effect below; here we only fetch when there's a target.
if ((!targetMessageId && !targetThreadRootId) || selectedPostId) {
setTargetMessageEvents([]);
return () => {
isCancelled = true;
};
}
const cachedTarget = getCachedSearchHitEvent(targetMessageId);
setTargetMessageEvents(cachedTarget ? [cachedTarget] : []);
if (cachedTarget) {
setTargetMessageEvents((currentEvents) =>
currentEvents.some((event) => event.id === cachedTarget.id)
? currentEvents
: [...currentEvents, cachedTarget],
);
}
const eventIds = [
targetMessageId,

View File

@@ -5,6 +5,7 @@ import {
formatDayHeading,
formatShortMonthDayOrdinal,
formatThreadSummaryLastReplyTime,
startOfLocalDaySeconds,
} from "./dateFormatters.ts";
function localUnixSeconds(year, monthIndex, day) {
@@ -126,3 +127,22 @@ test("formatDayHeading includes the year for other years", () => {
`${weekday(date)}, May 19th, ${year}`,
);
});
test("startOfLocalDaySeconds collapses a day's timestamps to one value", () => {
const morning = new Date(2026, 5, 14, 8, 30, 15).getTime() / 1_000;
const evening = new Date(2026, 5, 14, 23, 59, 59).getTime() / 1_000;
const midnight = new Date(2026, 5, 14, 0, 0, 0).getTime() / 1_000;
assert.equal(startOfLocalDaySeconds(morning), midnight);
assert.equal(startOfLocalDaySeconds(evening), midnight);
});
test("startOfLocalDaySeconds separates adjacent calendar days", () => {
const lateOn14 = new Date(2026, 5, 14, 23, 0, 0).getTime() / 1_000;
const earlyOn15 = new Date(2026, 5, 15, 1, 0, 0).getTime() / 1_000;
assert.notEqual(
startOfLocalDaySeconds(lateOn14),
startOfLocalDaySeconds(earlyOn15),
);
});

View File

@@ -78,6 +78,18 @@ export function isSameDay(a: number, b: number): boolean {
return isSameDayDate(new Date(a * 1_000), new Date(b * 1_000));
}
/**
* Unix-seconds timestamp of local midnight for the calendar day containing
* `unixSeconds`. Two timestamps on the same calendar day map to the same value,
* so it is a stable identifier for a day group that does not shift when an
* older message is prepended into that day.
*/
export function startOfLocalDaySeconds(unixSeconds: number): number {
const date = new Date(unixSeconds * 1_000);
date.setHours(0, 0, 0, 0);
return Math.floor(date.getTime() / 1_000);
}
/** Short month + ordinal day, e.g. "May 19th". */
export function formatShortMonthDayOrdinal(unixSeconds: number): string {
return formatMonthDayOrdinal(

View File

@@ -1,7 +1,10 @@
import assert from "node:assert/strict";
import test from "node:test";
import { formatTimelineMessages } from "./formatTimelineMessages.ts";
import {
countTopLevelTimelineRows,
formatTimelineMessages,
} from "./formatTimelineMessages.ts";
const HEX64_A =
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
@@ -100,3 +103,94 @@ test("deletion target with non-hex `e` tag value is ignored", () => {
"malformed deletion tag should not match anything",
);
});
// ---------------------------------------------------------------------------
// countTopLevelTimelineRows — the unit fetch-older pages by. Must match the
// rows `buildMainTimelineEntries` would actually render: top-level content
// events, minus deletions, with thread replies collapsed into their parent.
// ---------------------------------------------------------------------------
function hex64(char) {
return char.repeat(64);
}
function message(id, overrides = {}) {
return {
id,
pubkey: PUBKEY_A,
kind: 9,
created_at: 1_700_000_000,
content: "hi",
tags: [["h", CHANNEL_ID]],
sig: "sig",
...overrides,
};
}
function reply(id, parentId, overrides = {}) {
return message(id, {
tags: [
["h", CHANNEL_ID],
["e", parentId, "", "reply"],
],
...overrides,
});
}
test("countTopLevelTimelineRows counts top-level messages", () => {
const events = [
message(hex64("1")),
message(hex64("2")),
message(hex64("3")),
];
assert.equal(countTopLevelTimelineRows(events), 3);
});
test("countTopLevelTimelineRows ignores collapsed thread replies", () => {
const root = hex64("1");
const events = [
message(root),
reply(hex64("2"), root),
reply(hex64("3"), root),
];
// Two replies collapse into the root's summary → one visible row.
assert.equal(countTopLevelTimelineRows(events), 1);
});
test("countTopLevelTimelineRows counts broadcast replies as top-level", () => {
const root = hex64("1");
const broadcast = reply(hex64("2"), root, {
tags: [
["h", CHANNEL_ID],
["e", root, "", "reply"],
["broadcast", "1"],
],
});
assert.equal(countTopLevelTimelineRows([message(root), broadcast]), 2);
});
test("countTopLevelTimelineRows excludes deleted messages", () => {
const target = hex64("1");
const events = [
message(target),
message(hex64("2")),
deletionEvent(9005, target, { id: hex64("9") }),
];
assert.equal(countTopLevelTimelineRows(events), 1);
});
test("countTopLevelTimelineRows ignores non-content kinds (reactions)", () => {
const reaction = {
id: hex64("9"),
pubkey: PUBKEY_B,
kind: 7,
created_at: 1_700_000_001,
content: "+",
tags: [
["h", CHANNEL_ID],
["e", hex64("1")],
],
sig: "sig",
};
assert.equal(countTopLevelTimelineRows([message(hex64("1")), reaction]), 1);
});

View File

@@ -9,7 +9,10 @@ import type {
TimelineMessage,
TimelineReaction,
} from "@/features/messages/types";
import { getThreadReference } from "@/features/messages/lib/threading";
import {
getThreadReference,
isBroadcastReply,
} from "@/features/messages/lib/threading";
import {
resolveUserLabel,
type UserProfileLookup,
@@ -66,6 +69,47 @@ function getDeletionTargets(tags: string[][]) {
.map((tag) => tag[1]);
}
/**
* Count the *visible top-level rows* a raw event window would render in the
* main channel timeline — the same unit `buildMainTimelineEntries` produces.
*
* This is deliberately NOT `events.length`: thread replies collapse into their
* parent's summary row, deleted events disappear, and non-content kinds
* (reactions, edits, deletions) never render as their own row. A history batch
* heavy with replies can add 100 events but only a handful of rows, which is
* why fetch-older counts rows here, not messages, when deciding how far to page.
*
* Mirrors the two filters that bound the rendered list:
* 1. `formatTimelineMessages` keeps content kinds that aren't deletion targets.
* 2. `buildMainTimelineEntries` keeps entries that are top-level
* (`parentId == null`) or broadcast replies.
*/
export function countTopLevelTimelineRows(events: RelayEvent[]): number {
const deletedEventIds = new Set<string>();
for (const event of events) {
if (
event.kind === KIND_DELETION ||
event.kind === KIND_NIP29_DELETE_EVENT
) {
for (const targetId of getDeletionTargets(event.tags)) {
deletedEventIds.add(targetId);
}
}
}
let count = 0;
for (const event of events) {
if (!isTimelineContentEvent(event) || deletedEventIds.has(event.id)) {
continue;
}
const { parentId } = getThreadReference(event.tags);
if (parentId == null || isBroadcastReply(event.tags)) {
count += 1;
}
}
return count;
}
function getReactionTargetId(tags: string[][]) {
for (let index = tags.length - 1; index >= 0; index -= 1) {
const tag = tags[index];

View File

@@ -16,7 +16,7 @@ export type MessageLinkInput = {
*
* Currently emitted into the URL but not consumed by the click handler
* or deep-link listener — both route via `goChannel(channelId,
* { messageId })` and let `useTimelineScrollManager` resolve the target.
* { messageId })` and let `useAnchoredScroll` resolve the target.
* Reserved for future "open in thread view" routing.
*/
threadRootId?: string | null;

View File

@@ -212,6 +212,31 @@ test("buildDayGroupBoundaries: group counts always sum to message count", () =>
assert.equal(total, messages.length);
});
test("buildDayGroupBoundaries: same-day group key is stable across a prepend", () => {
// The day section is keyed by this value; if it changes when an older
// message lands on the same calendar day, React remounts the whole section
// on every scroll-up prepend — the timeline flicker. The key must depend on
// the calendar day, not the first message's exact timestamp.
const before = buildDayGroupBoundaries([
message({ id: "b", createdAt: dayAt(2026, 6, 14, 9) }),
message({ id: "c", createdAt: dayAt(2026, 6, 14, 10) }),
]);
const afterPrepend = buildDayGroupBoundaries([
message({ id: "a", createdAt: dayAt(2026, 6, 14, 8) }),
message({ id: "b", createdAt: dayAt(2026, 6, 14, 9) }),
message({ id: "c", createdAt: dayAt(2026, 6, 14, 10) }),
]);
assert.equal(before[0].key, afterPrepend[0].key);
});
test("buildDayGroupBoundaries: distinct calendar days get distinct keys", () => {
const groups = buildDayGroupBoundaries([
message({ id: "a", createdAt: dayAt(2026, 6, 13, 12) }),
message({ id: "b", createdAt: dayAt(2026, 6, 14, 12) }),
]);
assert.notEqual(groups[0].key, groups[1].key);
});
// --- jump-to-message deep links ----------------------------------------------
test("resolveDeepLinkTarget: unresolved with no target", () => {

View File

@@ -11,7 +11,7 @@
*/
import type { TimelineMessage } from "@/features/messages/types";
import { isSameDay } from "./dateFormatters";
import { isSameDay, startOfLocalDaySeconds } from "./dateFormatters";
/** Distance (px) from the bottom within which the timeline counts as "at bottom". */
export const BOTTOM_THRESHOLD_PX = 72;
@@ -88,7 +88,11 @@ export function selectLatestMessageAutoScrollBehavior({
/** A single day boundary in the timeline: where it starts and how many messages it covers. */
export type DayGroupBoundary = {
/** Stable key for the day section. */
/**
* Stable key for the day section: the local start-of-day of the messages it
* covers, so prepending an older message into an already-rendered day reuses
* the same key instead of remounting the whole `<section>`.
*/
key: string;
/** Index into `messages` of the first message in this day. */
startIndex: number;
@@ -114,7 +118,7 @@ export function buildDayGroupBoundaries(
if (!prev || !isSameDay(prev.createdAt, message.createdAt)) {
boundaries.push({
key: `day-${message.createdAt}`,
key: `day-${startOfLocalDaySeconds(message.createdAt)}`,
startIndex: i,
count: 1,
headingTimestamp: message.createdAt,

View File

@@ -3,6 +3,7 @@ import test from "node:test";
import {
buildVideoReviewCommentsByRootId,
buildVideoReviewCommentsForRoot,
buildVideoReviewContextForMessage,
hasVideoAttachment,
} from "./videoReviewContext.ts";
@@ -99,6 +100,64 @@ test("buildVideoReviewCommentsByRootId includes nested descendants", () => {
);
});
test("buildVideoReviewCommentsForRoot returns descendants for one root", () => {
const video = message({
id: "video",
body: "![video](https://relay/media/a.mp4)",
createdAt: 1,
});
const otherVideo = message({
id: "other-video",
body: "![video](https://relay/media/b.mp4)",
createdAt: 2,
});
const firstComment = message({
id: "first-comment",
body: "[00:01] tighten this",
createdAt: 4,
parentId: "video",
rootId: "video",
});
const nestedReply = message({
id: "nested-reply",
body: "agreed",
createdAt: 5,
parentId: "first-comment",
rootId: "video",
});
const earlierComment = message({
id: "earlier-comment",
body: "[00:00] opener",
createdAt: 3,
parentId: "video",
rootId: "video",
});
const otherComment = message({
id: "other-comment",
body: "different root",
createdAt: 6,
parentId: "other-video",
rootId: "other-video",
});
const comments = buildVideoReviewCommentsForRoot(
[
video,
otherVideo,
firstComment,
nestedReply,
earlierComment,
otherComment,
],
"video",
);
assert.deepEqual(
comments.map((comment) => comment.id),
["earlier-comment", "first-comment", "nested-reply"],
);
});
test("buildVideoReviewContextForMessage posts against the source video", async () => {
const video = message({
id: "video",

View File

@@ -61,6 +61,38 @@ export function buildVideoReviewCommentsByRootId(
return commentsByRootId;
}
export function buildVideoReviewCommentsForRoot(
messages: TimelineMessage[],
rootId: string,
): TimelineMessage[] {
const messageById = new Map(messages.map((message) => [message.id, message]));
const comments: TimelineMessage[] = [];
for (const message of messages) {
let ancestorId = message.parentId ?? null;
let hops = 0;
const maxHops = messages.length + 1;
while (ancestorId && hops < maxHops) {
if (ancestorId === rootId) {
comments.push(message);
break;
}
ancestorId = messageById.get(ancestorId)?.parentId ?? null;
hops += 1;
}
}
comments.sort((left, right) => {
if (left.createdAt !== right.createdAt) {
return left.createdAt - right.createdAt;
}
return left.id.localeCompare(right.id);
});
return comments;
}
export function buildVideoReviewContextForMessage({
channelId,
channelName,

View File

@@ -2,7 +2,7 @@ export function DayDivider({ label }: { label: string }) {
return (
<section
aria-label={label}
className="sticky top-[92px] z-[5] flex justify-center py-1"
className="sticky top-[92px] z-[5] flex justify-center py-1 before:absolute before:inset-x-0 before:top-1/2 before:h-px before:-translate-y-1/2 before:bg-border/35 before:content-['']"
data-testid="message-timeline-day-divider"
data-day-label={label}
>

View File

@@ -31,7 +31,7 @@ import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow";
import { TypingIndicatorRow } from "./TypingIndicatorRow";
import { UnreadDivider } from "./UnreadDivider";
import { useComposerHeightPadding } from "./useComposerHeightPadding";
import { useTimelineScrollManager } from "./useTimelineScrollManager";
import { useAnchoredScroll } from "./useAnchoredScroll";
import { selectDeferredListRenderState } from "@/features/messages/lib/timelineSnapshot";
type MessageThreadPanelProps = {
@@ -306,6 +306,11 @@ export function MessageThreadPanel({
widthPx,
}: MessageThreadPanelProps) {
const threadBodyRef = React.useRef<HTMLDivElement>(null);
const threadContentRef = React.useRef<HTMLDivElement>(null);
// Threads don't paginate older history, so this sentinel is never observed
// (the hook's older-history effect bails without a `fetchOlder`). It exists
// only to satisfy the hook's required ref contract.
const threadTopSentinelRef = React.useRef<HTMLDivElement>(null);
const threadComposerWrapperRef = React.useRef<HTMLDivElement>(null);
const isOverlay = useIsThreadPanelOverlay();
const isFloatingOverlay = isOverlay && !isSinglePanelView;
@@ -338,8 +343,7 @@ export function MessageThreadPanel({
// (`scrollIntoView`), so it must stay consistent with what's actually painted.
// You can't scroll to a reply that hasn't committed yet. The thread pane gets
// this no-tearing guarantee for free by routing through the same
// `useTimelineScrollManager` (and its `timelineSnapshot` helpers) as the main
// timeline.
// `useAnchoredScroll` primitive as the main timeline.
const deferredThreadReplies = React.useDeferredValue(
threadReplies,
EMPTY_THREAD_REPLIES,
@@ -359,22 +363,18 @@ export function MessageThreadPanel({
[deferredThreadReplies],
);
const {
bottomAnchorRef,
contentRef,
isAtBottom,
newMessageCount,
scrollToBottom,
syncScrollState,
} = useTimelineScrollManager({
channelId: threadHeadId,
// Wait for deferred replies to commit before scroll-init (else rows mount un-scrolled).
isLoading: repliesRenderState === "pending",
messages: threadMessages,
onTargetReached: onScrollTargetResolved,
scrollContainerRef: threadBodyRef,
targetMessageId: scrollTargetId,
});
const { isAtBottom, newMessageCount, onScroll, scrollToBottom } =
useAnchoredScroll({
channelId: threadHeadId,
contentRef: threadContentRef,
// Wait for deferred replies to commit before scroll-init (else rows mount un-scrolled).
isLoading: repliesRenderState === "pending",
messages: threadMessages,
onTargetReached: onScrollTargetResolved,
scrollContainerRef: threadBodyRef,
sentinelRef: threadTopSentinelRef,
targetMessageId: scrollTargetId,
});
if (!threadHead) {
return null;
@@ -388,10 +388,11 @@ export function MessageThreadPanel({
!isSplitLayout && !isFloatingOverlay && "pt-[4.75rem]",
)}
data-testid="message-thread-body"
onScroll={syncScrollState}
onScroll={onScroll}
ref={threadBodyRef}
>
<div ref={contentRef}>
<div ref={threadContentRef}>
<div ref={threadTopSentinelRef} aria-hidden className="h-px" />
<div className="px-3 pb-1 pt-0" data-testid="message-thread-head">
<div className="rounded-2xl">
<MessageRow
@@ -497,7 +498,6 @@ export function MessageThreadPanel({
// rows are streaming in on the deferred commit. Paint nothing rather
// than flashing the empty state.
null}
<div aria-hidden className="h-px" ref={bottomAnchorRef} />
</div>
</div>
</div>

View File

@@ -17,8 +17,7 @@ import { UnreadPill, unreadCountLabel } from "@/shared/ui/UnreadPill";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import { TimelineSkeleton, useTimelineSkeletonRows } from "./TimelineSkeleton";
import { TimelineMessageList } from "./TimelineMessageList";
import { useLoadOlderOnScroll } from "./useLoadOlderOnScroll";
import { useTimelineScrollManager } from "./useTimelineScrollManager";
import { useAnchoredScroll } from "./useAnchoredScroll";
export type MessageTimelineHandle = {
scrollToBottomOnNextUpdate: () => void;
@@ -161,6 +160,7 @@ const MessageTimelineBase = React.forwardRef<
) {
const internalScrollRef = React.useRef<HTMLDivElement>(null);
const scrollContainerRef = externalScrollRef ?? internalScrollRef;
const contentRef = React.useRef<HTMLDivElement>(null);
const topSentinelRef = React.useRef<HTMLDivElement>(null);
// Gate the heavy timeline render (each row runs a synchronous
@@ -203,22 +203,24 @@ const MessageTimelineBase = React.forwardRef<
const showMessageList = timelineBodySurface === "list";
const {
bottomAnchorRef,
contentRef,
highlightedMessageId,
isAtBottom,
newMessageCount,
restoreScrollPosition,
onScroll,
scrollToBottom,
scrollToBottomOnNextUpdate,
scrollToMessage,
syncScrollState,
} = useTimelineScrollManager({
} = useAnchoredScroll({
channelId,
contentRef,
fetchOlder,
hasOlderMessages,
isFetchingOlder,
isLoading: showTimelineSkeleton,
messages: deferredMessages,
onTargetReached,
scrollContainerRef,
sentinelRef: topSentinelRef,
targetMessageId,
});
@@ -263,9 +265,10 @@ const MessageTimelineBase = React.forwardRef<
}
}, [firstUnreadMessageId, scrollToMessage]);
// Scroll to the active search match when it changes.
// Scroll to the active search match when it changes. `scrollToMessage`
// updates the scroll anchor, so the post-commit restore won't yank the
// view back off the match.
const prevSearchActiveRef = React.useRef<string | null>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: scrollContainerRef is a stable React ref
React.useEffect(() => {
if (showTimelineSkeleton) return;
if (
@@ -276,26 +279,8 @@ const MessageTimelineBase = React.forwardRef<
return;
}
prevSearchActiveRef.current = searchActiveMessageId;
const container = scrollContainerRef.current;
if (!container) return;
const el = container.querySelector<HTMLElement>(
`[data-message-id="${searchActiveMessageId}"]`,
);
if (el) {
el.scrollIntoView({ block: "center", behavior: "smooth" });
}
}, [searchActiveMessageId, showTimelineSkeleton]);
useLoadOlderOnScroll({
fetchOlder,
hasOlderMessages,
isLoading: showTimelineSkeleton,
restoreScrollPosition,
scrollContainerRef,
sentinelRef: topSentinelRef,
});
scrollToMessage(searchActiveMessageId, { behavior: "smooth" });
}, [scrollToMessage, searchActiveMessageId, showTimelineSkeleton]);
const timelineSkeletonRows = useTimelineSkeletonRows({
channelId,
@@ -328,7 +313,7 @@ const MessageTimelineBase = React.forwardRef<
)}
data-scroll-restoration-id={scrollRestorationId}
data-testid="message-timeline"
onScroll={syncScrollState}
onScroll={onScroll}
ref={scrollContainerRef}
>
<div
@@ -359,7 +344,7 @@ const MessageTimelineBase = React.forwardRef<
) : null}
{activeDirectMessageIntro ? (
<div
className="mb-0.5 mt-auto flex w-full flex-col items-start px-3 py-2 text-left"
className="mt-auto flex w-full flex-col items-start px-3 py-2 text-left"
data-testid="message-dm-intro"
>
<DirectMessageIntroAvatarStack
@@ -380,7 +365,7 @@ const MessageTimelineBase = React.forwardRef<
{activeChannelIntro ? (
<div
className="mb-0.5 mt-auto flex w-full max-w-2xl flex-col items-start px-3 py-2 text-left"
className="mt-auto flex w-full max-w-2xl flex-col items-start px-3 py-2 text-left"
data-testid="message-channel-intro"
>
<div
@@ -520,8 +505,6 @@ const MessageTimelineBase = React.forwardRef<
</div>
) : null}
</div>
<div aria-hidden className="h-px" ref={bottomAnchorRef} />
</div>
</div>

View File

@@ -1,15 +1,19 @@
import * as React from "react";
import { formatDayHeading } from "@/features/messages/lib/dateFormatters";
import {
formatDayHeading,
isSameDay,
startOfLocalDaySeconds,
} from "@/features/messages/lib/dateFormatters";
import {
buildMainTimelineEntries,
shouldRenderUnreadDivider,
} from "@/features/messages/lib/threadPanel";
import {
buildVideoReviewCommentsByRootId,
buildVideoReviewCommentsForRoot,
buildVideoReviewContextForMessage,
hasVideoAttachment,
} from "@/features/messages/lib/videoReviewContext";
import { buildDayGroupBoundaries } from "@/features/messages/lib/timelineSnapshot";
import type { TimelineMessage } from "@/features/messages/types";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { ChannelType } from "@/shared/api/types";
@@ -65,6 +69,76 @@ type TimelineMessageListProps = {
threadUnreadCounts?: ReadonlyMap<string, number>;
};
type TimelineDayRow = {
key: string;
label: string;
type: "day";
};
type TimelineUnreadRow = {
key: string;
type: "unread";
};
type TimelineMessageRowModel = {
key: string;
message: TimelineMessage;
summary: ReturnType<typeof buildMainTimelineEntries>[number]["summary"];
type: "message";
};
type TimelineRenderRow =
| TimelineDayRow
| TimelineUnreadRow
| TimelineMessageRowModel;
function buildTimelineRenderRows({
firstUnreadMessageId,
messages,
}: {
firstUnreadMessageId: string | null;
messages: TimelineMessage[];
}): TimelineRenderRow[] {
const entries = buildMainTimelineEntries(messages);
const rows: TimelineRenderRow[] = [];
let previousMessage: TimelineMessage | null = null;
for (let i = 0; i < entries.length; i++) {
const { message, summary } = entries[i];
const messageRenderKey = message.renderKey ?? message.id;
if (
!previousMessage ||
!isSameDay(previousMessage.createdAt, message.createdAt)
) {
rows.push({
key: `day-${startOfLocalDaySeconds(message.createdAt)}`,
label: formatDayHeading(message.createdAt),
type: "day",
});
}
// The unread "New" divider only marks a read/unread boundary when there is
// a message above the first unread. When the first unread is the first
// rendered top-level entry (fresh/never-read channel), there is nothing
// above to separate from, so it is suppressed.
if (shouldRenderUnreadDivider(i, message.id, firstUnreadMessageId)) {
rows.push({ key: `unread:${messageRenderKey}`, type: "unread" });
}
rows.push({
key: `msg:${messageRenderKey}`,
message,
summary,
type: "message",
});
previousMessage = message;
}
return rows;
}
export const TimelineMessageList = React.memo(function TimelineMessageList({
agentPubkeys,
channelId,
@@ -91,207 +165,226 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
threadUnreadCounts,
unfollowThreadById,
}: TimelineMessageListProps) {
const entries = React.useMemo(
() => buildMainTimelineEntries(messages),
[messages],
const rows = React.useMemo(
() => buildTimelineRenderRows({ firstUnreadMessageId, messages }),
[firstUnreadMessageId, messages],
);
const reviewCommentsByRootId = React.useMemo(
() => buildVideoReviewCommentsByRootId(messages),
[messages],
);
// Contexts are memoized per message id so MessageRow/Markdown memo
// comparisons hold across unrelated timeline re-renders (typing
// indicators, presence updates) — a fresh context object per render would
// defeat the memo and re-render every video message on every pass.
const videoReviewContextById = React.useMemo(() => {
const contexts = new Map<
string,
NonNullable<ReturnType<typeof buildVideoReviewContextForMessage>>
>();
for (const message of messages) {
const comments = reviewCommentsByRootId.get(message.id) ?? [];
const context = buildVideoReviewContextForMessage({
channelId,
channelName,
channelType,
comments,
isSendingVideoReviewComment,
message,
onSendVideoReviewComment,
onToggleReaction,
profiles,
});
if (context) {
contexts.set(message.id, context);
return rows.map((row) => (
<TimelineRenderRowView
agentPubkeys={agentPubkeys}
allMessages={
row.type === "message" && hasVideoAttachment(row.message)
? messages
: undefined
}
channelId={channelId}
channelName={channelName}
channelType={channelType}
currentPubkey={currentPubkey}
followThreadById={followThreadById}
highlightedMessageId={highlightedMessageId}
isFollowingThreadById={isFollowingThreadById}
isSendingVideoReviewComment={isSendingVideoReviewComment}
key={row.key}
messageFooters={messageFooters}
onDelete={onDelete}
onEdit={onEdit}
onMarkUnread={onMarkUnread}
onReply={onReply}
onSendVideoReviewComment={onSendVideoReviewComment}
onToggleReaction={onToggleReaction}
profiles={profiles}
row={row}
searchActiveMessageId={searchActiveMessageId}
searchMatchingMessageIds={searchMatchingMessageIds}
searchQuery={searchQuery}
threadUnreadCounts={threadUnreadCounts}
unfollowThreadById={unfollowThreadById}
/>
));
});
type TimelineRenderRowViewProps = Omit<
TimelineMessageListProps,
"firstUnreadMessageId" | "messages" | "personaLookup"
> & {
allMessages?: TimelineMessage[];
row: TimelineRenderRow;
};
const TimelineRenderRowView = React.memo(function TimelineRenderRowView({
agentPubkeys,
allMessages,
channelId,
channelName,
channelType,
currentPubkey,
followThreadById,
highlightedMessageId = null,
isFollowingThreadById,
isSendingVideoReviewComment = false,
messageFooters,
onDelete,
onEdit,
onMarkUnread,
onReply,
onSendVideoReviewComment,
onToggleReaction,
profiles,
searchActiveMessageId = null,
searchMatchingMessageIds,
searchQuery,
row,
threadUnreadCounts,
unfollowThreadById,
}: TimelineRenderRowViewProps) {
const messageForContext = row.type === "message" ? row.message : null;
const videoReviewContext = React.useMemo(() => {
if (!allMessages || !messageForContext) {
return undefined;
}
return contexts;
return buildVideoReviewContextForMessage({
channelId,
channelName,
channelType,
comments: buildVideoReviewCommentsForRoot(
allMessages,
messageForContext.id,
),
isSendingVideoReviewComment,
message: messageForContext,
onSendVideoReviewComment,
onToggleReaction,
profiles,
});
}, [
allMessages,
channelId,
channelName,
channelType,
isSendingVideoReviewComment,
messages,
messageForContext,
onSendVideoReviewComment,
onToggleReaction,
profiles,
reviewCommentsByRootId,
]);
const dayGroups: Array<{
key: string;
label: string;
elements: React.ReactNode[];
}> = [];
let currentDayGroup: (typeof dayGroups)[number] | null = null;
// Day-divider decision delegated to a pure, lib-tested helper: a new group
// starts at index 0 and whenever a message falls on a different calendar day
// than the one before it. We index the boundary start positions so the render
// loop below stays a straight walk while the grouping logic lives in `lib/`.
const dayGroupStartIndices = new Set(
buildDayGroupBoundaries(entries.map((entry) => entry.message)).map(
(boundary) => boundary.startIndex,
),
);
for (let i = 0; i < entries.length; i++) {
const { message, summary } = entries[i];
const messageRenderKey = message.renderKey ?? message.id;
if (dayGroupStartIndices.has(i)) {
currentDayGroup = {
key: `day-${message.createdAt}`,
label: formatDayHeading(message.createdAt),
elements: [],
};
dayGroups.push(currentDayGroup);
}
// The unread "New" divider only marks a read/unread boundary when there is
// a message above the first unread. When the first unread is the first
// rendered top-level entry (fresh/never-read channel), there is nothing
// above to separate from, so it is suppressed.
if (shouldRenderUnreadDivider(i, message.id, firstUnreadMessageId)) {
currentDayGroup?.elements.push(
<UnreadDivider key={`unread-${messageRenderKey}`} />,
);
}
if (message.kind === KIND_SYSTEM_MESSAGE) {
const footer = messageFooters?.[message.id] ?? null;
currentDayGroup?.elements.push(
<div key={messageRenderKey} className="flex flex-col gap-1">
<SystemMessageRow
message={message}
currentPubkey={currentPubkey}
onToggleReaction={onToggleReaction}
profiles={profiles}
/>
{footer}
</div>,
);
} else if (summary && onReply) {
const footer = messageFooters?.[message.id] ?? null;
const isHighlighted = message.id === highlightedMessageId;
currentDayGroup?.elements.push(
<div
key={messageRenderKey}
className={cn(
"group/message relative mx-1 flex flex-col gap-0 rounded-2xl px-0 py-1 transition-colors hover:bg-muted/50 focus-within:bg-muted/50",
isHighlighted &&
"-mx-4 px-4 before:absolute before:-inset-y-1.5 before:inset-x-0 before:animate-[route-target-highlight-fade_2s_ease-out_forwards] before:bg-primary/10 before:content-[''] motion-reduce:before:animate-none sm:-mx-6 sm:px-6",
)}
>
<MessageRow
agentPubkeys={agentPubkeys}
channelId={channelId}
highlighted={false}
hoverBackground={false}
isFollowingThread={
isFollowingThreadById
? isFollowingThreadById(message.id)
: undefined
}
message={message}
onDelete={
onDelete && currentPubkey && message.pubkey === currentPubkey
? onDelete
: undefined
}
onEdit={
onEdit && currentPubkey && message.pubkey === currentPubkey
? onEdit
: undefined
}
onFollowThread={
followThreadById ? () => followThreadById(message.id) : undefined
}
onMarkUnread={onMarkUnread}
onToggleReaction={onToggleReaction}
onReply={onReply}
onUnfollowThread={
unfollowThreadById
? () => unfollowThreadById(message.id)
: undefined
}
profiles={profiles}
showDepthGuides={false}
videoReviewContext={videoReviewContextById.get(message.id)}
/>
<MessageThreadSummaryRow
depth={message.depth}
message={message}
onOpenThread={onReply}
showDepthGuides={false}
summary={summary}
unreadCount={threadUnreadCounts?.get(message.id)}
/>
{footer}
</div>,
);
} else {
const isSearchMatch = searchMatchingMessageIds?.has(message.id) ?? false;
const isSearchActive = message.id === searchActiveMessageId;
const footer = messageFooters?.[message.id] ?? null;
currentDayGroup?.elements.push(
<div key={messageRenderKey} className="flex flex-col gap-1">
<MessageRow
agentPubkeys={agentPubkeys}
channelId={channelId}
highlighted={message.id === highlightedMessageId || isSearchActive}
message={message}
onDelete={
onDelete && currentPubkey && message.pubkey === currentPubkey
? onDelete
: undefined
}
onEdit={
onEdit && currentPubkey && message.pubkey === currentPubkey
? onEdit
: undefined
}
onMarkUnread={onMarkUnread}
onToggleReaction={onToggleReaction}
onReply={onReply}
profiles={profiles}
searchQuery={isSearchMatch ? searchQuery : undefined}
showDepthGuides={false}
videoReviewContext={videoReviewContextById.get(message.id)}
/>
{footer}
</div>,
);
}
if (row.type === "day") {
return <DayDivider label={row.label} />;
}
return dayGroups.map((group) => (
<section
className="relative flex flex-col gap-2.5 before:absolute before:inset-x-0 before:top-[15px] before:h-px before:bg-border/35 before:content-['']"
key={group.key}
>
<DayDivider label={group.label} />
{group.elements}
</section>
));
if (row.type === "unread") {
return <UnreadDivider />;
}
const { message, summary } = row;
if (message.kind === KIND_SYSTEM_MESSAGE) {
const footer = messageFooters?.[message.id] ?? null;
return (
<div className="flex flex-col gap-1">
<SystemMessageRow
message={message}
currentPubkey={currentPubkey}
onToggleReaction={onToggleReaction}
profiles={profiles}
/>
{footer}
</div>
);
}
if (summary && onReply) {
const footer = messageFooters?.[message.id] ?? null;
const isHighlighted = message.id === highlightedMessageId;
return (
<div
className={cn(
"group/message relative mx-1 flex flex-col gap-0 rounded-2xl px-0 py-1 transition-colors hover:bg-muted/50 focus-within:bg-muted/50",
isHighlighted &&
"-mx-4 px-4 before:absolute before:-inset-y-1.5 before:inset-x-0 before:animate-[route-target-highlight-fade_2s_ease-out_forwards] before:bg-primary/10 before:content-[''] motion-reduce:before:animate-none sm:-mx-6 sm:px-6",
)}
>
<MessageRow
agentPubkeys={agentPubkeys}
channelId={channelId}
highlighted={false}
hoverBackground={false}
isFollowingThread={
isFollowingThreadById
? isFollowingThreadById(message.id)
: undefined
}
message={message}
onDelete={
onDelete && currentPubkey && message.pubkey === currentPubkey
? onDelete
: undefined
}
onEdit={
onEdit && currentPubkey && message.pubkey === currentPubkey
? onEdit
: undefined
}
onFollowThread={
followThreadById ? () => followThreadById(message.id) : undefined
}
onMarkUnread={onMarkUnread}
onToggleReaction={onToggleReaction}
onReply={onReply}
onUnfollowThread={
unfollowThreadById
? () => unfollowThreadById(message.id)
: undefined
}
profiles={profiles}
showDepthGuides={false}
videoReviewContext={videoReviewContext}
/>
<MessageThreadSummaryRow
depth={message.depth}
message={message}
onOpenThread={onReply}
showDepthGuides={false}
summary={summary}
unreadCount={threadUnreadCounts?.get(message.id)}
/>
{footer}
</div>
);
}
const isSearchMatch = searchMatchingMessageIds?.has(message.id) ?? false;
const isSearchActive = message.id === searchActiveMessageId;
const footer = messageFooters?.[message.id] ?? null;
return (
<div className="flex flex-col gap-1">
<MessageRow
agentPubkeys={agentPubkeys}
channelId={channelId}
highlighted={message.id === highlightedMessageId || isSearchActive}
message={message}
onDelete={
onDelete && currentPubkey && message.pubkey === currentPubkey
? onDelete
: undefined
}
onEdit={
onEdit && currentPubkey && message.pubkey === currentPubkey
? onEdit
: undefined
}
onMarkUnread={onMarkUnread}
onToggleReaction={onToggleReaction}
onReply={onReply}
profiles={profiles}
searchQuery={isSearchMatch ? searchQuery : undefined}
showDepthGuides={false}
videoReviewContext={videoReviewContext}
/>
{footer}
</div>
);
});

View File

@@ -0,0 +1,626 @@
import * as React from "react";
import type { TimelineMessage } from "@/features/messages/types";
/**
* Distance (in CSS pixels) below which we consider the scroll position
* "at the bottom" of the message list. Tight enough that the user has to
* actually scroll down to re-pin; permissive enough to tolerate sub-pixel
* rounding from the layout engine.
*/
const AT_BOTTOM_THRESHOLD_PX = 32;
type AnchorState =
| { kind: "at-bottom" }
| { kind: "message"; messageId: string; topOffset: number };
type UseAnchoredScrollOptions = {
/** Scroll container. Owned by the parent so external refs still compose. */
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
/** Inner content element — must wrap every renderable row, including the
* sentinel and bottom anchor. Used to schedule layout work on resize. */
contentRef: React.RefObject<HTMLDivElement | null>;
/** Small zero-height element near the very top of the content. When it
* intersects the viewport (with some rootMargin) we trigger fetchOlder. */
sentinelRef: React.RefObject<HTMLDivElement | null>;
/** Resets when changed; lets us drop anchor + scroll state across channels. */
channelId?: string | null;
/** Suppresses initial scroll-to-bottom while a skeleton is showing. */
isLoading: boolean;
/** Source of truth for the rendered list. Used to detect new-at-bottom
* arrivals and to seed/refresh the anchor pre-render. */
messages: TimelineMessage[];
/** Optional callback to fetch older history. The hook handles intersection,
* debouncing, and post-prepend scroll restoration via the anchor. */
fetchOlder?: () => Promise<void>;
hasOlderMessages?: boolean;
/** True while an older-history fetch is in flight. The fetch spinner renders
* above the anchor, so toggling it shifts every row below it. The spinner
* toggles on its own commit (no message change), so without this signal the
* restoration effect — keyed on `messages` — wouldn't re-run to correct the
* shift, leaving a visible one-frame jump. Threading it through makes the
* anchor the single owner of every layout change above the reader's eye. */
isFetchingOlder?: boolean;
/** When set, scroll to and highlight this message on mount and on change. */
targetMessageId?: string | null;
onTargetReached?: (messageId: string) => void;
};
type UseAnchoredScrollResult = {
/** Pass through to the scroll container's `onScroll`. */
onScroll: () => void;
/** True when the user is within `AT_BOTTOM_THRESHOLD_PX` of the bottom. */
isAtBottom: boolean;
/** Number of new messages that have arrived while the user is not at the
* bottom. Cleared when the user returns to the bottom. */
newMessageCount: number;
/** Message id that should pulse a highlight (target/active-search). */
highlightedMessageId: string | null;
/** Imperative: scroll to bottom. */
scrollToBottom: (behavior?: ScrollBehavior) => void;
/** Arm a one-shot scroll-to-bottom that fires on the next appended message
* (used by the composer's send flow). */
scrollToBottomOnNextUpdate: () => void;
/** Imperative: scroll a specific message into view; optionally pulse it.
* Returns true if the row was found and scrolled, false otherwise. */
scrollToMessage: (
messageId: string,
options?: { highlight?: boolean; behavior?: ScrollBehavior },
) => boolean;
};
function isAtBottomNow(container: HTMLDivElement) {
return (
container.scrollHeight - container.clientHeight - container.scrollTop <=
AT_BOTTOM_THRESHOLD_PX
);
}
/**
* Pick an anchor for the current scroll position.
*
* Top-crossing walk: chronological children, top-down. The first
* `data-message-id` row whose bottom edge has crossed below the container
* top is the anchor — that's the row the reader's eye is on when they've
* scrolled up through history. `topOffset` is the row's top relative to
* the container's top and may be negative when the row straddles the edge.
*
* If no such row exists (e.g. nothing scrolled past the top, list shorter
* than the viewport, etc.) the anchor is `at-bottom`.
*
* Algorithm credit: Sami's [13] in the buzz-bugs scroll-redesign thread,
* supersedes the Matrix-style bottom-up walk in [7]. The top-crossing
* choice is what keeps the row the reader is *reading* fixed under
* in-viewport reflow (image-load, embed expansion).
*/
function computeAnchor(container: HTMLDivElement): AnchorState {
if (isAtBottomNow(container)) {
return { kind: "at-bottom" };
}
const containerTop = container.getBoundingClientRect().top;
const rows = container.querySelectorAll<HTMLElement>("[data-message-id]");
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const rect = row.getBoundingClientRect();
if (rect.bottom > containerTop) {
const messageId = row.dataset.messageId;
if (messageId) {
return {
kind: "message",
messageId,
topOffset: rect.top - containerTop,
};
}
}
}
return { kind: "at-bottom" };
}
/**
* Find the rendered message id that is closest in chronological order to
* the anchor, scanning forward in `messages`. Used as the fallback when the
* anchor's row is gone post-render (e.g. message deleted).
*/
function findNearestNewerMessageId(
container: HTMLDivElement,
messages: TimelineMessage[],
anchorId: string,
): string | null {
const anchorIndex = messages.findIndex((m) => m.id === anchorId);
if (anchorIndex < 0) return null;
for (let i = anchorIndex + 1; i < messages.length; i++) {
const candidate = messages[i];
const el = container.querySelector(`[data-message-id="${candidate.id}"]`);
if (el) return candidate.id;
}
return null;
}
/**
* Restore a message-kind anchor's on-screen offset after a layout shift.
*
* Finds the anchor row (or the nearest newer rendered row if the anchor
* itself was removed), measures its current top-relative offset, and
* `scrollBy(0, delta)` if the offset has drifted. Returns the new anchor
* state the caller should write back:
* - `{ kind: "message", ... }` — anchor (or its fallback) is in the DOM
* and now sits at its previous offset.
* - `{ kind: "at-bottom" }` — anchor and all newer rendered rows are gone;
* caller should pin to the bottom and update at-bottom state.
*
* `scrollBy` is intentional over `scrollTop = ...`: relative adjustment
* composes with the browser's own scroll anchoring and doesn't fight a
* smooth-scroll in flight. Same rationale as the layout-effect restore.
*
* Used by both the post-commit layout effect (prepend / append / spinner
* toggle / etc.) and the ResizeObserver (in-viewport reflow from image
* decode, embed expansion, font load). Keeping them on one primitive
* preserves the single-owner invariant of the hook.
*/
function restoreAnchorToMessage(
container: HTMLDivElement,
messages: TimelineMessage[],
anchor: Extract<AnchorState, { kind: "message" }>,
): AnchorState {
let anchorEl = container.querySelector<HTMLElement>(
`[data-message-id="${anchor.messageId}"]`,
);
let usedAnchor: AnchorState = anchor;
if (!anchorEl) {
const fallbackId = findNearestNewerMessageId(
container,
messages,
anchor.messageId,
);
if (fallbackId) {
anchorEl = container.querySelector<HTMLElement>(
`[data-message-id="${fallbackId}"]`,
);
if (anchorEl) {
usedAnchor = {
kind: "message",
messageId: fallbackId,
topOffset: anchor.topOffset,
};
}
}
}
if (!anchorEl) {
// Anchor message and all subsequent rendered messages are gone.
container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
return { kind: "at-bottom" };
}
const containerTop = container.getBoundingClientRect().top;
const currentTop = anchorEl.getBoundingClientRect().top - containerTop;
const delta = currentTop - usedAnchor.topOffset;
if (Math.abs(delta) > 0.5) {
container.scrollBy(0, delta);
}
return usedAnchor;
}
export function useAnchoredScroll({
scrollContainerRef,
contentRef,
sentinelRef,
channelId,
isLoading,
messages,
fetchOlder,
hasOlderMessages = false,
isFetchingOlder = false,
targetMessageId = null,
onTargetReached,
}: UseAnchoredScrollOptions): UseAnchoredScrollResult {
// Anchor lives in a ref because it must survive renders and is updated
// both on scroll (commit-time read) and in the layout effect (post-render
// restoration). useState would force re-renders we don't want.
const anchorRef = React.useRef<AnchorState>({ kind: "at-bottom" });
// Latest `messages` mirrored to a ref so the ResizeObserver effect can read
// the current list without re-subscribing the observer on every commit
// (which would also drop any in-flight resize callbacks). Kept fresh by a
// layout effect below so the read is consistent with what's in the DOM.
const messagesRef = React.useRef<TimelineMessage[]>(messages);
const [isAtBottom, setIsAtBottom] = React.useState(true);
const [newMessageCount, setNewMessageCount] = React.useState(0);
const [highlightedMessageId, setHighlightedMessageId] = React.useState<
string | null
>(null);
const hasInitializedRef = React.useRef(false);
const prevLastMessageIdRef = React.useRef<string | undefined>(undefined);
const prevMessageCountRef = React.useRef(0);
const fetchingOlderRef = React.useRef(false);
const handledTargetIdRef = React.useRef<string | null>(null);
const highlightTimeoutRef = React.useRef<number | null>(null);
// One-shot: the consumer calls `scrollToBottomOnNextUpdate()` right before
// it sends a message (see ChannelPane). When the user's own message then
// appends, we snap to bottom even if they had scrolled up to read history.
// Consumed (and cleared) by the next append in the restoration effect.
const forceBottomOnNextAppendRef = React.useRef(false);
// Reset everything when the channel changes — the layout effect that runs
// immediately after this reset is responsible for either jumping to bottom
// or to the target message for the new channel.
// biome-ignore lint/correctness/useExhaustiveDependencies: channelId is intentionally the sole trigger — we want this effect to fire exactly when the channel changes (and on mount).
React.useLayoutEffect(() => {
anchorRef.current = { kind: "at-bottom" };
setIsAtBottom(true);
setNewMessageCount(0);
setHighlightedMessageId(null);
hasInitializedRef.current = false;
prevLastMessageIdRef.current = undefined;
prevMessageCountRef.current = 0;
fetchingOlderRef.current = false;
handledTargetIdRef.current = null;
forceBottomOnNextAppendRef.current = false;
if (highlightTimeoutRef.current !== null) {
window.clearTimeout(highlightTimeoutRef.current);
highlightTimeoutRef.current = null;
}
}, [channelId]);
const scrollToBottomImperative = React.useCallback(
(behavior: ScrollBehavior = "auto") => {
const container = scrollContainerRef.current;
if (!container) return;
anchorRef.current = { kind: "at-bottom" };
container.scrollTo({ top: container.scrollHeight, behavior });
setIsAtBottom(true);
setNewMessageCount(0);
},
[scrollContainerRef],
);
// Arm a one-shot: the next append snaps to bottom regardless of where the
// user is. The consumer calls this right before sending so their own
// outbound message pulls the view down even if they'd scrolled up.
const scrollToBottomOnNextUpdate = React.useCallback(() => {
forceBottomOnNextAppendRef.current = true;
}, []);
const scrollToMessageImperative = React.useCallback(
(
messageId: string,
options: { highlight?: boolean; behavior?: ScrollBehavior } = {},
): boolean => {
const container = scrollContainerRef.current;
if (!container) return false;
const el = container.querySelector<HTMLElement>(
`[data-message-id="${messageId}"]`,
);
if (!el) return false;
const rect = el.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const currentTopOffset = rect.top - containerRect.top;
const centeredTopOffset = (container.clientHeight - rect.height) / 2;
const maxScrollTop = Math.max(
0,
container.scrollHeight - container.clientHeight,
);
const targetScrollTop = Math.min(
maxScrollTop,
Math.max(0, container.scrollTop + currentTopOffset - centeredTopOffset),
);
const targetTopOffset =
currentTopOffset - (targetScrollTop - container.scrollTop);
el.scrollIntoView({
block: "center",
behavior: options.behavior ?? "auto",
});
// `scrollIntoView({ behavior: "smooth" })` starts an async animation, so
// measuring after the call can still return the pre-animation position.
// Save the clamped destination offset instead; otherwise a concurrent
// render/ResizeObserver restore can fight the smooth scroll back toward
// where it started.
anchorRef.current = {
kind: "message",
messageId,
topOffset: targetTopOffset,
};
setIsAtBottom(maxScrollTop - targetScrollTop <= AT_BOTTOM_THRESHOLD_PX);
if (options.highlight) {
if (highlightTimeoutRef.current !== null) {
window.clearTimeout(highlightTimeoutRef.current);
}
setHighlightedMessageId(messageId);
highlightTimeoutRef.current = window.setTimeout(() => {
setHighlightedMessageId((current) =>
current === messageId ? null : current,
);
highlightTimeoutRef.current = null;
}, 2_000);
}
return true;
},
[scrollContainerRef],
);
// Scroll handler: recompute anchor + bottom state from the current
// scroll position. Cheap enough to run on every scroll event — a single
// `getBoundingClientRect` walk plus rect reads.
const onScroll = React.useCallback(() => {
const container = scrollContainerRef.current;
if (!container) return;
anchorRef.current = computeAnchor(container);
const atBottom = anchorRef.current.kind === "at-bottom";
setIsAtBottom((prev) => (prev === atBottom ? prev : atBottom));
if (atBottom) {
setNewMessageCount(0);
}
}, [scrollContainerRef]);
// ---------------------------------------------------------------------------
// Anchor restoration: after every render, if the anchor was a message,
// realign so that message sits at the same top-relative offset it had
// before the render. This is the single mechanism for keeping scroll
// stable across prepends, appends, image loads, embed expansions, etc.
// ---------------------------------------------------------------------------
// biome-ignore lint/correctness/useExhaustiveDependencies: `isFetchingOlder` is an intentional re-run trigger, not a read — the fetch spinner renders above the anchor on its own commit (with `messages` unchanged), so we re-run restoration on its toggle to correct the spinner-induced shift via the existing anchor.
React.useLayoutEffect(() => {
const container = scrollContainerRef.current;
if (!container) return;
// Mirror the current messages list into the ref read by the
// ResizeObserver's restore path. Must happen before any early return so
// a non-React layout shift sees the same array the next restoration
// would use.
messagesRef.current = messages;
// First render after a reset (channel switch or initial mount): jump
// to the requested target message, or to the bottom by default.
if (!hasInitializedRef.current) {
if (isLoading) return;
if (targetMessageId) {
// A cold deep-link target may not be in the DOM on this first
// commit — the route screen fetches it by id and splices it in a
// render or two later. If centering fails now, leave the timeline at
// its default position and let the post-mount target effect (keyed on
// `messages`) retry once the row lands, rather than marking it handled.
if (scrollToMessageImperative(targetMessageId, { highlight: true })) {
handledTargetIdRef.current = targetMessageId;
// Consumers clear the route target (`messageId` URL param) on this
// callback. The post-mount target effect below also fires it, but
// for a target already in the DOM on first commit that effect bails
// (handled ref is set), so the initial path must fire it too — else
// the param sticks and re-clicking the same deep link is a no-op.
onTargetReached?.(targetMessageId);
} else {
scrollToBottomImperative("auto");
}
} else {
scrollToBottomImperative("auto");
}
hasInitializedRef.current = true;
prevLastMessageIdRef.current = messages[messages.length - 1]?.id;
prevMessageCountRef.current = messages.length;
return;
}
const anchor = anchorRef.current;
const lastMessage = messages[messages.length - 1];
const prevLastId = prevLastMessageIdRef.current;
const prevCount = prevMessageCountRef.current;
const newLatestArrived =
lastMessage !== undefined && lastMessage.id !== prevLastId;
// One-shot: an outbound send armed `scrollToBottomOnNextUpdate`. When the
// resulting append lands, snap to bottom regardless of the current anchor,
// then clear the flag. Bail before the anchored branch so the user's own
// message pulls the view down.
if (newLatestArrived && forceBottomOnNextAppendRef.current) {
forceBottomOnNextAppendRef.current = false;
container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
anchorRef.current = { kind: "at-bottom" };
setIsAtBottom(true);
setNewMessageCount(0);
prevLastMessageIdRef.current = lastMessage?.id;
prevMessageCountRef.current = messages.length;
return;
}
if (anchor.kind === "at-bottom") {
// Stick to bottom. Use scrollTo to avoid relying on scroll anchoring.
container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
if (newLatestArrived) setNewMessageCount(0);
} else {
// Anchored to a specific message. The shared helper finds it (with a
// nearest-newer fallback if the row was removed) and corrects the
// offset via `scrollBy`. If both the anchor and all newer rendered
// rows are gone, it pins to the bottom and returns `at-bottom`.
const restored = restoreAnchorToMessage(container, messages, anchor);
anchorRef.current = restored;
if (restored.kind === "at-bottom") {
setIsAtBottom(true);
}
if (newLatestArrived) {
const added = Math.max(1, messages.length - prevCount);
setNewMessageCount((current) => current + added);
}
}
prevLastMessageIdRef.current = lastMessage?.id;
prevMessageCountRef.current = messages.length;
}, [
isFetchingOlder,
isLoading,
messages,
onTargetReached,
scrollContainerRef,
scrollToBottomImperative,
scrollToMessageImperative,
targetMessageId,
]);
// ---------------------------------------------------------------------------
// Older-history loader. IntersectionObserver on the top sentinel; when it
// crosses into view (with a 200px rootMargin so we preload a bit early)
// we fire `fetchOlder`. The anchor restoration above handles the prepend
// — we don't need to compute or apply a scrollHeight delta ourselves.
// ---------------------------------------------------------------------------
React.useEffect(() => {
const sentinel = sentinelRef.current;
const container = scrollContainerRef.current;
if (
!sentinel ||
!container ||
!fetchOlder ||
isLoading ||
!hasOlderMessages
) {
return;
}
let disposed = false;
let observer: IntersectionObserver | null = null;
const start = () => {
if (disposed) return;
observer = new IntersectionObserver(
([entry]) => {
if (!entry?.isIntersecting || disposed || fetchingOlderRef.current) {
return;
}
fetchingOlderRef.current = true;
observer?.disconnect();
// Before the fetch, capture the anchor from the current scroll
// position. The layout effect after re-render will use it.
anchorRef.current = computeAnchor(container);
void fetchOlder()
.catch(() => {
// Swallow; the next intersection will retry. We don't want
// to crash the observer chain on a transient relay error.
})
.finally(() => {
fetchingOlderRef.current = false;
// Re-observe in case there's more history to load.
start();
});
},
{ root: container, rootMargin: "200px 0px 0px 0px" },
);
observer.observe(sentinel);
};
start();
return () => {
disposed = true;
observer?.disconnect();
};
}, [
fetchOlder,
hasOlderMessages,
isLoading,
scrollContainerRef,
sentinelRef,
]);
// ---------------------------------------------------------------------------
// Content resize: when fonts load late, an image decodes, an embed expands,
// or any in-viewport reflow happens that React isn't driving (so the
// layout-effect doesn't fire), the anchor row's on-screen offset drifts.
//
// When stuck-to-bottom we re-pin to bottom. When anchored to a message we
// call the same restore primitive the layout effect uses, so an in-viewport
// reflow above the reader's eye shifts back into place. Without this,
// anything that resizes without changing `messages` (link-card decode,
// async embed expand, late font load, markdown that expands) silently
// pushes the reading row around.
// ---------------------------------------------------------------------------
React.useEffect(() => {
const content = contentRef.current;
if (!content || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => {
const container = scrollContainerRef.current;
if (!container) return;
const anchor = anchorRef.current;
if (anchor.kind === "at-bottom") {
container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
return;
}
// Use the same restore primitive as the layout effect so the
// single-owner model holds across non-React-driven layout shifts.
const restored = restoreAnchorToMessage(
container,
messagesRef.current,
anchor,
);
anchorRef.current = restored;
if (restored.kind === "at-bottom") {
setIsAtBottom(true);
}
});
observer.observe(content);
return () => observer.disconnect();
}, [contentRef, scrollContainerRef]);
// ---------------------------------------------------------------------------
// Target message handling (deep link, jump-to-reply, etc.). Distinct from
// the initial-mount target above — this handles changes after the first
// render.
//
// A deep-link target may live in older history that isn't in the DOM when
// the route param first changes. The route screen fetches the target event
// by id and splices it into `messages` asynchronously, so its row appears a
// render or two later. We therefore key this effect on `messages` and bail
// *without* marking the target handled until its row actually exists — each
// subsequent message commit re-runs the effect and retries the centering.
// ---------------------------------------------------------------------------
// biome-ignore lint/correctness/useExhaustiveDependencies: `messages` is an intentional trigger, not a read — the effect reads the DOM (querySelector), and we need it to re-run each time the rendered row set changes so a target spliced into older history gets centered once its row commits.
React.useEffect(() => {
if (!targetMessageId) {
handledTargetIdRef.current = null;
return;
}
if (handledTargetIdRef.current === targetMessageId || isLoading) return;
if (!hasInitializedRef.current) return; // initial-mount path will handle.
const container = scrollContainerRef.current;
if (!container) return;
const el = container.querySelector<HTMLElement>(
`[data-message-id="${targetMessageId}"]`,
);
if (!el) return; // Row not rendered yet; a later `messages` commit retries.
handledTargetIdRef.current = targetMessageId;
scrollToMessageImperative(targetMessageId, { highlight: true });
onTargetReached?.(targetMessageId);
}, [
isLoading,
messages,
onTargetReached,
scrollContainerRef,
scrollToMessageImperative,
targetMessageId,
]);
React.useEffect(() => {
return () => {
if (highlightTimeoutRef.current !== null) {
window.clearTimeout(highlightTimeoutRef.current);
}
};
}, []);
return {
onScroll,
isAtBottom,
newMessageCount,
highlightedMessageId,
scrollToBottom: scrollToBottomImperative,
scrollToBottomOnNextUpdate,
scrollToMessage: scrollToMessageImperative,
};
}

View File

@@ -1,92 +0,0 @@
import * as React from "react";
type UseLoadOlderOnScrollOptions = {
fetchOlder?: () => Promise<void>;
hasOlderMessages: boolean;
isLoading: boolean;
restoreScrollPosition: (scrollTop: number) => void;
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
sentinelRef: React.RefObject<HTMLDivElement | null>;
};
/**
* Triggers `fetchOlder` when a sentinel element near the top of the scroll
* container enters the viewport, then restores the scroll position so the
* visible content doesn't jump.
*/
export function useLoadOlderOnScroll({
fetchOlder,
hasOlderMessages,
isLoading,
restoreScrollPosition,
scrollContainerRef,
sentinelRef,
}: UseLoadOlderOnScrollOptions) {
const restoreScrollPositionRef = React.useRef(restoreScrollPosition);
React.useEffect(() => {
restoreScrollPositionRef.current = restoreScrollPosition;
});
React.useEffect(() => {
const sentinel = sentinelRef.current;
const container = scrollContainerRef.current;
if (
!sentinel ||
!container ||
!fetchOlder ||
isLoading ||
!hasOlderMessages
) {
return;
}
let disposed = false;
let currentObserver: IntersectionObserver | null = null;
const observe = () => {
if (disposed) {
return;
}
currentObserver = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting || disposed) {
return;
}
currentObserver?.disconnect();
const previousHeight = container.scrollHeight;
const previousScrollTop = container.scrollTop;
void fetchOlder().then(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const newHeight = container.scrollHeight;
const delta = newHeight - previousHeight;
if (delta > 0) {
restoreScrollPositionRef.current(previousScrollTop + delta);
}
observe();
});
});
});
},
{ root: container, rootMargin: "200px 0px 0px 0px" },
);
currentObserver.observe(sentinel);
};
observe();
return () => {
disposed = true;
currentObserver?.disconnect();
};
}, [
fetchOlder,
hasOlderMessages,
isLoading,
scrollContainerRef,
sentinelRef,
]);
}

View File

@@ -1,464 +0,0 @@
import * as React from "react";
import {
isNearBottom,
resolveDeepLinkTarget,
selectLatestMessageAutoScrollBehavior,
selectLatestMessageKey,
} from "@/features/messages/lib/timelineSnapshot";
import type { TimelineMessage } from "@/features/messages/types";
type UseTimelineScrollManagerOptions = {
channelId?: string | null;
isLoading: boolean;
messages: TimelineMessage[];
onTargetReached?: (messageId: string) => void;
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
targetMessageId?: string | null;
};
type PinToBottomOptions = {
clearNewMessageCount?: boolean;
};
export function useTimelineScrollManager({
channelId,
isLoading,
messages,
onTargetReached,
scrollContainerRef,
targetMessageId,
}: UseTimelineScrollManagerOptions) {
const timelineRef = scrollContainerRef;
const contentRef = React.useRef<HTMLDivElement>(null);
const bottomAnchorRef = React.useRef<HTMLDivElement>(null);
const hasInitializedRef = React.useRef(false);
const shouldStickToBottomRef = React.useRef(true);
const isAtBottomRef = React.useRef(true);
const isProgrammaticBottomScrollRef = React.useRef(false);
const previousTimelineHeightRef = React.useRef<number | null>(null);
const previousScrollTopRef = React.useRef(0);
const lockedScrollTopRef = React.useRef<number | null>(null);
const previousLastMessageKeyRef = React.useRef<string | undefined>(undefined);
const previousMessageCountRef = React.useRef(0);
const handledTargetMessageIdRef = React.useRef<string | null>(null);
const scrollToBottomOnNextUpdateRef = React.useRef(false);
// Mirror isLoading into a ref so the ResizeObservers (which subscribe once)
// can skip reacting while the skeleton is up — reacting to height churn under
// a streaming-in list is what makes the timeline thrash on entry.
const isLoadingRef = React.useRef(isLoading);
isLoadingRef.current = isLoading;
const [isAtBottom, setIsAtBottom] = React.useState(true);
const [highlightedMessageId, setHighlightedMessageId] = React.useState<
string | null
>(null);
const [newMessageCount, setNewMessageCount] = React.useState(0);
const resetScrollTracking = React.useCallback(() => {
hasInitializedRef.current = false;
shouldStickToBottomRef.current = true;
isAtBottomRef.current = true;
isProgrammaticBottomScrollRef.current = false;
previousTimelineHeightRef.current = null;
previousScrollTopRef.current = 0;
lockedScrollTopRef.current = null;
previousLastMessageKeyRef.current = undefined;
previousMessageCountRef.current = 0;
handledTargetMessageIdRef.current = null;
scrollToBottomOnNextUpdateRef.current = false;
setIsAtBottom(true);
setHighlightedMessageId(null);
setNewMessageCount(0);
}, []);
const pinToBottom = React.useCallback(
({ clearNewMessageCount = false }: PinToBottomOptions = {}) => {
shouldStickToBottomRef.current = true;
isAtBottomRef.current = true;
setIsAtBottom((current) => (current ? current : true));
if (clearNewMessageCount) {
setNewMessageCount(0);
}
},
[],
);
const setObservedBottomState = React.useCallback((atBottom: boolean) => {
shouldStickToBottomRef.current = atBottom;
isAtBottomRef.current = atBottom;
setIsAtBottom((current) => (current === atBottom ? current : atBottom));
if (atBottom) {
setNewMessageCount(0);
}
}, []);
const unpinFromBottom = React.useCallback((scrollTop: number) => {
shouldStickToBottomRef.current = false;
isAtBottomRef.current = false;
isProgrammaticBottomScrollRef.current = false;
previousScrollTopRef.current = scrollTop;
setIsAtBottom(false);
}, []);
// biome-ignore lint/correctness/useExhaustiveDependencies: channelId is intentionally the sole trigger — we reset all scroll state when the channel changes
React.useLayoutEffect(() => {
resetScrollTracking();
}, [channelId, resetScrollTracking]);
const latestMessage =
messages.length > 0 ? messages[messages.length - 1] : undefined;
const latestMessageKey = selectLatestMessageKey(messages);
const scrollToBottomOnNextUpdate = React.useCallback(() => {
scrollToBottomOnNextUpdateRef.current = true;
}, []);
// biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref passed from the parent — its identity never changes
const syncScrollState = React.useCallback(() => {
const timeline = timelineRef.current;
if (!timeline) {
return;
}
const scrollTop = lockedScrollTopRef.current ?? timeline.scrollTop;
const atBottom = isNearBottom(timeline);
const movedAwayFromBottom = scrollTop + 1 < previousScrollTopRef.current;
if (isProgrammaticBottomScrollRef.current) {
previousScrollTopRef.current = scrollTop;
if (movedAwayFromBottom) {
isProgrammaticBottomScrollRef.current = false;
} else if (!atBottom) {
pinToBottom();
return;
} else {
isProgrammaticBottomScrollRef.current = false;
pinToBottom({ clearNewMessageCount: true });
return;
}
}
if (shouldStickToBottomRef.current && !atBottom && !movedAwayFromBottom) {
previousScrollTopRef.current = scrollTop;
pinToBottom({ clearNewMessageCount: true });
return;
}
previousScrollTopRef.current = scrollTop;
setObservedBottomState(atBottom);
}, [pinToBottom, setObservedBottomState]);
// biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref — its identity never changes
const restoreScrollPosition = React.useCallback(
(scrollTop: number) => {
const timeline = timelineRef.current;
if (!timeline) {
return;
}
isProgrammaticBottomScrollRef.current = false;
lockedScrollTopRef.current = scrollTop;
const restore = (remainingFrames: number) => {
timeline.scrollTop = scrollTop;
if (remainingFrames > 0) {
requestAnimationFrame(() => {
restore(remainingFrames - 1);
});
return;
}
lockedScrollTopRef.current = null;
previousScrollTopRef.current = timeline.scrollTop;
syncScrollState();
};
restore(2);
},
[syncScrollState],
);
// biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref — its identity never changes
const scrollToBottom = React.useCallback(
(behavior: ScrollBehavior) => {
const timeline = timelineRef.current;
if (!timeline) {
return;
}
isProgrammaticBottomScrollRef.current = true;
const alignToBottom = (nextBehavior: ScrollBehavior) => {
bottomAnchorRef.current?.scrollIntoView({
block: "end",
behavior: nextBehavior,
});
timeline.scrollTo({
top: timeline.scrollHeight,
behavior: nextBehavior,
});
};
alignToBottom(behavior);
lockedScrollTopRef.current = null;
previousScrollTopRef.current = timeline.scrollTop;
pinToBottom({ clearNewMessageCount: true });
if (behavior === "smooth") {
requestAnimationFrame(() => {
previousScrollTopRef.current = timeline.scrollTop;
syncScrollState();
});
return;
}
const settleAlignment = (remainingFrames: number) => {
requestAnimationFrame(() => {
alignToBottom("auto");
previousScrollTopRef.current = timeline.scrollTop;
if (remainingFrames > 0) {
settleAlignment(remainingFrames - 1);
return;
}
syncScrollState();
});
};
settleAlignment(2);
},
[pinToBottom, syncScrollState],
);
// biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref — its identity never changes
React.useEffect(() => {
const timeline = timelineRef.current;
if (!timeline || typeof ResizeObserver === "undefined") {
return;
}
previousTimelineHeightRef.current = timeline.clientHeight;
previousScrollTopRef.current = timeline.scrollTop;
const observer = new ResizeObserver(([entry]) => {
const previousTimelineHeight = previousTimelineHeightRef.current;
const nextTimelineHeight = entry.contentRect.height;
previousTimelineHeightRef.current = nextTimelineHeight;
// Track height while loading, but don't scroll — the init layout-effect
// owns the first scroll once content settles.
if (isLoadingRef.current) {
return;
}
if (
previousTimelineHeight === null ||
Math.abs(nextTimelineHeight - previousTimelineHeight) < 1
) {
return;
}
if (shouldStickToBottomRef.current || isAtBottomRef.current) {
scrollToBottom("auto");
return;
}
restoreScrollPosition(previousScrollTopRef.current);
});
observer.observe(timeline);
return () => {
observer.disconnect();
};
}, [restoreScrollPosition, scrollToBottom]);
React.useEffect(() => {
const content = contentRef.current;
if (!content || typeof ResizeObserver === "undefined") {
return;
}
const observer = new ResizeObserver(() => {
if (isLoadingRef.current) {
return;
}
if (shouldStickToBottomRef.current) {
scrollToBottom("auto");
return;
}
syncScrollState();
});
observer.observe(content);
return () => {
observer.disconnect();
};
}, [scrollToBottom, syncScrollState]);
React.useLayoutEffect(() => {
if (!hasInitializedRef.current) {
if (isLoading) {
return;
}
if (targetMessageId) {
const timeline = timelineRef.current;
unpinFromBottom(timeline?.scrollTop ?? 0);
} else {
scrollToBottom("auto");
}
hasInitializedRef.current = true;
previousLastMessageKeyRef.current = latestMessageKey;
previousMessageCountRef.current = messages.length;
return;
}
const previousLastMessageKey = previousLastMessageKeyRef.current;
const previousMessageCount = previousMessageCountRef.current;
const hasNewLatestMessage =
latestMessage !== undefined &&
latestMessageKey !== previousLastMessageKey;
if (!hasNewLatestMessage) {
previousLastMessageKeyRef.current = latestMessageKey;
previousMessageCountRef.current = messages.length;
return;
}
const shouldHonorExplicitBottomRequest =
scrollToBottomOnNextUpdateRef.current;
scrollToBottomOnNextUpdateRef.current = false;
const autoScrollBehavior = selectLatestMessageAutoScrollBehavior({
hasExplicitBottomRequest: shouldHonorExplicitBottomRequest,
isAtBottom: isAtBottomRef.current,
shouldStickToBottom: shouldStickToBottomRef.current,
targetMessageId,
});
if (autoScrollBehavior) {
scrollToBottom(autoScrollBehavior);
} else {
setNewMessageCount((current) => {
const addedMessages = Math.max(
1,
messages.length - previousMessageCount,
);
return current + addedMessages;
});
}
previousLastMessageKeyRef.current = latestMessageKey;
previousMessageCountRef.current = messages.length;
}, [
isLoading,
latestMessage,
latestMessageKey,
messages.length,
scrollToBottom,
targetMessageId,
timelineRef,
unpinFromBottom,
]);
// biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref — its identity never changes
const scrollToMessage = React.useCallback(
(messageId: string) => {
const timeline = timelineRef.current;
if (!timeline) {
return false;
}
const targetElement = timeline.querySelector<HTMLElement>(
`[data-message-id="${messageId}"]`,
);
if (!targetElement) {
return false;
}
unpinFromBottom(timeline.scrollTop);
setHighlightedMessageId(messageId);
setNewMessageCount(0);
const alignToTarget = (remainingFrames: number) => {
targetElement.scrollIntoView({
block: "center",
behavior: "auto",
});
previousScrollTopRef.current = timeline.scrollTop;
if (remainingFrames > 0) {
requestAnimationFrame(() => {
alignToTarget(remainingFrames - 1);
});
return;
}
onTargetReached?.(messageId);
};
alignToTarget(2);
window.setTimeout(() => {
setHighlightedMessageId((current) =>
current === messageId ? null : current,
);
}, 2_000);
return true;
},
[onTargetReached, unpinFromBottom],
);
React.useEffect(() => {
if (!targetMessageId) {
handledTargetMessageIdRef.current = null;
setHighlightedMessageId(null);
return;
}
if (handledTargetMessageIdRef.current === targetMessageId || isLoading) {
return;
}
// Deep-link decision delegated to a pure, lib-tested helper: only attempt the
// jump once the target actually exists in THIS (deferred) snapshot. If it
// doesn't, the row hasn't committed yet — bail and let the next snapshot that
// includes it drive the jump. This reads the same `messages` snapshot the
// list rendered, which closes the tearing race.
if (!resolveDeepLinkTarget(messages, targetMessageId).resolved) {
return;
}
if (!scrollToMessage(targetMessageId)) {
return;
}
handledTargetMessageIdRef.current = targetMessageId;
}, [isLoading, messages, scrollToMessage, targetMessageId]);
return {
bottomAnchorRef,
contentRef,
highlightedMessageId,
isAtBottom,
newMessageCount,
restoreScrollPosition,
scrollToBottom,
scrollToBottomOnNextUpdate,
scrollToMessage,
syncScrollState,
};
}

View File

@@ -1,6 +1,7 @@
import { useCallback, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { countTopLevelTimelineRows } from "@/features/messages/lib/formatTimelineMessages";
import {
channelMessagesKey,
mergeTimelineHistoryMessages,
@@ -10,6 +11,14 @@ import type { Channel, RelayEvent } from "@/shared/api/types";
const OLDER_MESSAGES_BATCH_SIZE = 100;
// One scroll-up should advance the timeline by a predictable, *visible* amount.
// Because thread replies collapse into their parent and non-content events
// never render, a single 100-message batch can add far fewer rows than that —
// so we page in additional batches until at least this many top-level rows have
// been added (or history runs out). Counting rows, not messages, keeps a
// reply-heavy window from feeling like the fetch did nothing.
const MIN_TOP_LEVEL_ROWS_PER_FETCH = 10;
export function useFetchOlderMessages(channel: Channel | null) {
const queryClient = useQueryClient();
const channelId = channel?.id ?? null;
@@ -43,39 +52,62 @@ export function useFetchOlderMessages(channel: Channel | null) {
return;
}
// Use the oldest timestamp directly — `until` is inclusive so the relay will
// return the boundary message again, but `sortMessages` deduplicates by id.
// Subtracting 1 risks skipping messages that share the same second.
const oldestTimestamp = currentMessages[0].created_at;
isFetchingOlderRef.current = true;
setIsFetchingOlder(true);
// Page in batches until the timeline has gained at least
// MIN_TOP_LEVEL_ROWS_PER_FETCH *visible* rows or history is exhausted.
// A single batch is fetched first; only reply-heavy windows that fall short
// of the floor loop again. Each batch re-reads the oldest timestamp from the
// cache so successive `until` values keep walking backward without gaps.
const baselineRowCount = countTopLevelTimelineRows(currentMessages);
try {
const olderMessages = await relayClient.fetchChannelHistoryBefore(
channelId,
oldestTimestamp,
OLDER_MESSAGES_BATCH_SIZE,
);
while (hasOlderMessagesRef.current) {
const messagesBeforeFetch =
queryClient.getQueryData<RelayEvent[]>(queryKey) ?? [];
if (messagesBeforeFetch.length === 0) {
break;
}
if (olderMessages.length < OLDER_MESSAGES_BATCH_SIZE) {
hasOlderMessagesRef.current = false;
setHasOlderMessages(false);
}
if (olderMessages.length > 0) {
queryClient.setQueryData<RelayEvent[]>(queryKey, (current = []) =>
mergeTimelineHistoryMessages(current, olderMessages),
// Use the oldest timestamp directly — `until` is inclusive so the relay
// will return the boundary message again, but `sortMessages`
// deduplicates by id. Subtracting 1 risks skipping messages that share
// the same second.
const oldestTimestamp = messagesBeforeFetch[0].created_at;
const olderMessages = await relayClient.fetchChannelHistoryBefore(
channelId,
oldestTimestamp,
OLDER_MESSAGES_BATCH_SIZE,
);
const updatedMessages =
queryClient.getQueryData<RelayEvent[]>(queryKey) ?? [];
if (
updatedMessages.length > 0 &&
updatedMessages[0].created_at === oldestTimestamp
) {
if (olderMessages.length < OLDER_MESSAGES_BATCH_SIZE) {
hasOlderMessagesRef.current = false;
setHasOlderMessages(false);
}
if (olderMessages.length > 0) {
queryClient.setQueryData<RelayEvent[]>(queryKey, (current = []) =>
mergeTimelineHistoryMessages(current, olderMessages),
);
const updatedMessages =
queryClient.getQueryData<RelayEvent[]>(queryKey) ?? [];
if (
updatedMessages.length > 0 &&
updatedMessages[0].created_at === oldestTimestamp
) {
hasOlderMessagesRef.current = false;
setHasOlderMessages(false);
}
}
const rowsGained =
countTopLevelTimelineRows(
queryClient.getQueryData<RelayEvent[]>(queryKey) ?? [],
) - baselineRowCount;
if (rowsGained >= MIN_TOP_LEVEL_ROWS_PER_FETCH) {
break;
}
}
} catch (error) {
console.error("Failed to fetch older messages", channelId, error);

View File

@@ -144,6 +144,32 @@ function aspectRatioFromDim(dim?: string): number | undefined {
return width / height;
}
/**
* Parse a NIP-92 `dim` value ("WxH") into intrinsic pixel dimensions. Used to
* stamp explicit `width`/`height` attributes on inline images so the browser
* reserves aspect-ratio-correct layout space *before* the image decodes. This
* is what keeps the timeline from jumping when a tall image loads late — the
* row's height is known at first paint instead of growing from ~0 on load.
*/
function dimensionsFromDim(
dim?: string,
): { width: number; height: number } | undefined {
if (!dim) return undefined;
const match = dim.match(/^(\d+)x(\d+)$/i);
if (!match) return undefined;
const width = Number(match[1]);
const height = Number(match[2]);
if (
!Number.isFinite(width) ||
!Number.isFinite(height) ||
width <= 0 ||
height <= 0
) {
return undefined;
}
return { width, height };
}
function isInsideHiddenSpoiler(element: Element): boolean {
return (
element.closest('.buzz-spoiler[data-spoiler][data-revealed="false"]') !==
@@ -1004,10 +1030,12 @@ function ImageZoomOverlay({
*/
function ImageBlock({
alt,
dim,
resolvedSrc,
src,
}: {
alt: string | undefined;
dim?: string;
resolvedSrc: string | undefined;
src: string | undefined;
}) {
@@ -1093,6 +1121,12 @@ function ImageBlock({
const closeMenu = React.useCallback(() => setMenu(null), []);
useDismissImageContextMenu(Boolean(menu), closeMenu);
// Intrinsic dimensions from the NIP-92 `dim` tag, stamped as width/height
// attributes so the browser reserves aspect-ratio space before the image
// decodes. Without this the row grows from ~0 on load and shoves the
// timeline — the exact reflow the anchored-scroll restore then has to fight.
const intrinsicDimensions = dimensionsFromDim(dim);
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
if (isInsideHiddenSpoiler(e.currentTarget)) return;
@@ -1153,9 +1187,11 @@ function ImageBlock({
alt={alt}
className="block max-h-64 max-w-sm rounded-xl object-contain"
data-spoiler-media-size={hiddenSpoilerMediaSize ? "" : undefined}
height={intrinsicDimensions?.height}
ref={imageRef}
src={resolvedSrc}
style={spoilerMediaStyle}
width={intrinsicDimensions?.width}
onLoad={(event) => updateSpoilerMediaSize(event.currentTarget)}
/>
</button>
@@ -1860,9 +1896,15 @@ function createMarkdownComponents(
</span>
);
}
const entry = src ? imetaByUrl?.get(src) : undefined;
return (
<span data-block-media="" className="block">
<ImageBlock alt={alt} resolvedSrc={resolvedSrc} src={src} />
<ImageBlock
alt={alt}
dim={entry?.dim}
resolvedSrc={resolvedSrc}
src={src}
/>
</span>
);
},
@@ -2094,7 +2136,7 @@ function MarkdownInner({
(link: ParsedMessageLink) => {
// Always route through `goChannel` with `messageId` set: the channel
// route already handles scroll-into-view + highlight via
// `useTimelineScrollManager` + `getEventById` backfill, and works for
// `useAnchoredScroll` + `getEventById` backfill, and works for
// both stream-message replies and forum threads. Detecting "the thread
// root is a forum post" up front would require an event lookup we don't
// currently have synchronously; the brief explicitly allows skipping

View File

@@ -72,6 +72,9 @@ type E2eConfig = {
feedReadError?: string;
canvasReadError?: string;
openDmDelayMs?: number;
/** Delay (ms) applied to older-history (`history-` subId) fetches so e2e
* tests can observe the in-flight prepend window. 0/undefined = instant. */
historyDelayMs?: number;
profileReadDelayMs?: number;
profileReadError?: string;
profileUpdateError?: string;
@@ -593,6 +596,17 @@ declare global {
extraTags?: string[][];
createdAt?: number;
}) => RelayEvent;
/** Prepend `count` synthetic older messages to a channel's mock store so
* an older-history fetch has something to paginate. Mirrors how the real
* relay backfills history. Returns the created events. */
__BUZZ_E2E_PREPEND_MOCK_HISTORY__?: (input: {
channelName: string;
count: number;
startIndex?: number;
lineCount?: number;
createdAtStart?: number;
emit?: boolean;
}) => RelayEvent[];
__BUZZ_E2E_EMIT_MOCK_TYPING__?: (input: {
channelName: string;
pubkey?: string;
@@ -2267,6 +2281,60 @@ function getMockMessageStore(channelId: string): RelayEvent[] {
return seeded;
}
function prependMockHistory(input: {
channelName: string;
count: number;
startIndex?: number;
lineCount?: number;
createdAtStart?: number;
emit?: boolean;
}) {
const channel = mockChannels.find(
(candidate) => candidate.name === input.channelName,
);
if (!channel) {
throw new Error(`Unknown mock channel: ${input.channelName}`);
}
const store = getMockMessageStore(channel.id);
const earliestCreatedAt = store.reduce(
(earliest, event) => Math.min(earliest, event.created_at),
Math.floor(Date.now() / 1000),
);
const createdAtStart =
input.createdAtStart ?? earliestCreatedAt - input.count - 1;
const startIndex = input.startIndex ?? 0;
const lineCount = input.lineCount ?? 1;
const events = Array.from({ length: input.count }, (_, offset) => {
const index = startIndex + offset;
const body = Array.from(
{ length: lineCount },
(_unused, lineIndex) => `mock older ${index} line ${lineIndex + 1}`,
).join("\n");
return createMockEvent(
9,
body,
[["h", channel.id]],
ALICE_PUBKEY,
createdAtStart + offset,
`mock-older-${channel.name}-${index}`.replace(/[^a-zA-Z0-9]/g, ""),
);
});
store.unshift(...events);
store.sort((left, right) => left.created_at - right.created_at);
if (input.emit) {
for (const event of events) {
emitMockLiveEvent(channel.id, event);
}
}
return events;
}
function emitMockHistory(
socket: MockSocket,
subId: string,
@@ -2290,10 +2358,24 @@ function emitMockHistory(
.slice(0, filter.limit ?? 50)
.sort((left, right) => left.created_at - right.created_at);
for (const event of events) {
sendWsText(socket.handler, ["EVENT", subId, event]);
const emit = () => {
for (const event of events) {
sendWsText(socket.handler, ["EVENT", subId, event]);
}
sendWsText(socket.handler, ["EOSE", subId]);
};
// Optionally pace older-history fetches so e2e tests can observe the
// in-flight prepend window (scroll up, abandon, etc.). Scoped to
// `history-` subscriptions — the prefix `relayClientSession` uses for
// older-message pagination — so live/initial subscriptions stay instant.
const delayMs = getConfig()?.mock?.historyDelayMs ?? 0;
if (delayMs > 0 && subId.startsWith("history-")) {
window.setTimeout(emit, delayMs);
return;
}
sendWsText(socket.handler, ["EOSE", subId]);
emit();
}
function emitMockLiveEvent(channelId: string, event: RelayEvent) {
@@ -5628,7 +5710,7 @@ async function handleGetEvent(
"bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260",
created_at: Math.floor(Date.now() / 1000) - 42 * 60,
kind: 9,
tags: [["e", "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9"]],
tags: [["h", "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9"]],
content: "Engineering shipped the desktop build.",
sig: "mocksig".repeat(20).slice(0, 128),
},
@@ -6049,6 +6131,7 @@ export function maybeInstallE2eTauriMocks() {
createdAt,
);
};
window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__ = prependMockHistory;
window.__BUZZ_E2E_EMIT_MOCK_TYPING__ = ({ channelName, pubkey }) => {
const channel = mockChannels.find(
(candidate) => candidate.name === channelName,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,311 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
/**
* Scroll-smoothness measurement harness.
*
* This is NOT a pass/fail correctness test — it's an instrument. It seeds a
* busy channel, mounts the full history into the DOM, drives a synthetic
* fast-wheel scroll up through it, and measures the *main-thread layout work*
* that scroll triggers — the cost the headless correctness suite cannot feel
* (it polls only the final position).
*
* WHY LAYOUT COST, NOT FRAME CADENCE: under Playwright's headed Chromium there
* is no vsync throttle, so requestAnimationFrame fires far faster than a real
* 60Hz display and "frame interval" tells you nothing about paint jank. The
* honest, engine-agnostic signal is the cumulative layout / style-recalc time
* Chromium spends servicing the scroll burst, read via CDP Performance metrics.
* More live DOM rows + per-row reflow on scroll == more layout cost == the jank
* a user feels. That is exactly the axis virtualization / content-visibility
* attack, so it's the axis worth a baseline.
*
* SCOPE LIMIT (stated honestly): this measures Chromium's main-thread layout
* cost. It does NOT measure the WKWebView compositor / stale-scrollTop race
* Sami flagged as the dominant *feel* hazard on the shipped Tauri app — that
* only reproduces in the real desktop shell and is Tyler's real-wheel pass.
*
* Run headed to watch it:
* pnpm build && npx playwright test --config=playwright.perf.config.ts --headed
*/
const SEED_ROWS = 600; // a genuinely busy channel, fully mounted
const LINES_PER_ROW = 3;
type Sample = {
rowCount: number;
scrollSpan: number;
frames: number;
};
type Metrics = { layoutMs: number; recalcMs: number; layoutCount: number };
async function readMetrics(
client: import("@playwright/test").CDPSession,
): Promise<Metrics> {
const { metrics } = (await client.send("Performance.getMetrics")) as {
metrics: Array<{ name: string; value: number }>;
};
const m = (name: string) => metrics.find((x) => x.name === name)?.value ?? 0;
return {
// CDP reports durations in seconds; convert to ms.
layoutMs: m("LayoutDuration") * 1000,
recalcMs: m("RecalcStyleDuration") * 1000,
layoutCount: m("LayoutCount"),
};
}
test("MEASURE: fast-wheel scroll-up layout cost on a busy un-virtualized timeline", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await page.waitForFunction(
() =>
typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" &&
typeof window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__ === "function",
);
await page.evaluate(
({ rows, lines }) => {
// Emit the entire busy channel as live messages so the FULL list mounts
// into the DOM up front — no dependence on the app's fetch-older
// pagination (which caps per-request and is a separate axis). This is the
// un-virtualized DOM we're here to stress.
for (let i = 0; i < rows; i += 1) {
const body = Array.from(
{ length: lines },
(_u, l) => `busy row ${i} line ${l + 1}`,
).join("\n");
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: body,
});
}
},
{ rows: SEED_ROWS, lines: LINES_PER_ROW },
);
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const timeline = page.getByTestId("message-timeline");
// Confirm the list is mounted before measuring. Capture the actual mounted
// count — we don't assume all SEED_ROWS render (the app may cap/window).
await page.waitForFunction(() => {
const el = document.querySelector(
'[data-testid="message-timeline"]',
) as HTMLDivElement | null;
return !!el && el.querySelectorAll("[data-message-id]").length > 50;
});
await page.waitForTimeout(500); // let live emits settle
const client = await page.context().newCDPSession(page);
await client.send("Performance.enable");
// Reset to bottom, settle, then snapshot metrics around the scroll burst.
await timeline.evaluate((element) => {
const el = element as HTMLDivElement;
el.scrollTop = el.scrollHeight;
el.dispatchEvent(new Event("scroll", { bubbles: true }));
});
await page.waitForTimeout(100);
const before = await readMetrics(client);
const sample = await timeline.evaluate(async (element): Promise<Sample> => {
const el = element as HTMLDivElement;
el.scrollTop = el.scrollHeight;
const startTop = el.scrollTop;
let frames = 0;
await new Promise<void>((resolve) => {
let elapsed = 0;
let last = performance.now();
const DURATION_MS = 2_000;
const PX_PER_FRAME = 50; // brisk human flick
const step = (now: number) => {
elapsed += now - last;
last = now;
frames += 1;
el.dispatchEvent(
new WheelEvent("wheel", {
deltaY: -PX_PER_FRAME,
bubbles: true,
cancelable: true,
}),
);
el.scrollTop = Math.max(0, el.scrollTop - PX_PER_FRAME);
el.dispatchEvent(new Event("scroll", { bubbles: true }));
if (elapsed < DURATION_MS && el.scrollTop > 0) {
requestAnimationFrame(step);
} else {
resolve();
}
};
requestAnimationFrame(step);
});
return {
rowCount: el.querySelectorAll("[data-message-id]").length,
scrollSpan: startTop - el.scrollTop,
frames,
};
});
const after = await readMetrics(client);
await client.send("Performance.disable");
const layoutDelta = after.layoutMs - before.layoutMs;
const recalcDelta = after.recalcMs - before.recalcMs;
const layoutCountDelta = after.layoutCount - before.layoutCount;
const perScroll = sample.frames > 0 ? sample.frames : 1;
/* eslint-disable no-console */
console.log("\n=== SCROLL SMOOTHNESS BASELINE (Chromium layout cost) ===");
console.log(`rows mounted (live DOM): ${sample.rowCount}`);
console.log(
`scroll span covered: ${Math.round(sample.scrollSpan)}px`,
);
console.log(`scroll frames driven: ${sample.frames}`);
console.log(`layout time over burst: ${layoutDelta.toFixed(1)}ms`);
console.log(`style-recalc time over burst: ${recalcDelta.toFixed(1)}ms`);
console.log(`forced layouts (count delta): ${layoutCountDelta}`);
console.log(
`avg layout+recalc per frame: ${((layoutDelta + recalcDelta) / perScroll).toFixed(3)}ms`,
);
console.log("(>~8ms/frame main-thread work is where 120Hz starts to drop)");
console.log("=========================================================\n");
/* eslint-enable no-console */
// Instrument, not a gate — just confirm it exercised the full list.
expect(sample.rowCount).toBeGreaterThan(100);
expect(sample.scrollSpan).toBeGreaterThan(500);
});
/**
* MEASURE: prepend re-render cost — the one event-cost the team had no number
* for. Steady-state scroll of the bounded 200-row window is compositor-cheap
* (see the test above). The felt jank, if any, lives at the *prepend*: when
* older rows land while you're scrolled up, the whole memoized
* TimelineMessageList re-renders AND the anchor scrollBy fires, all on one
* main-thread tick. If that tick exceeds the frame budget mid-wheel, the
* compositor stalls and you feel it.
*
* This drives the genuine path: seed older history, scroll off-bottom, then
* prepend a small batch via the live-event ingest (the same WS path the relay
* uses), and measure (a) the synchronous wall-time of the tick that flushes
* the React commit + layout, and (b) the CDP layout/recalc cost attributed to
* it. It also records whether the anchor held (scrollTop preserved within the
* row that was under the eye) — a non-restore here is the in-viewport-shift
* bug, not a perf cost.
*
* SCOPE LIMIT: same as above — Chromium main-thread cost, not WKWebView feel.
*/
test("MEASURE: prepend re-render cost while scrolled up (the untested event-cost)", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await page.waitForFunction(
() =>
typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" &&
typeof window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__ === "function",
);
// Seed the channel at roughly the real data-window cap (CHANNEL_HISTORY_LIMIT
// = 200) so the prepend re-renders a full-size list, the worst realistic case.
const SEED = 200;
await page.evaluate(
({ rows, lines }) => {
for (let i = 0; i < rows; i += 1) {
const body = Array.from(
{ length: lines },
(_u, l) => `seed row ${i} line ${l + 1}`,
).join("\n");
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: body,
});
}
},
{ rows: SEED, lines: LINES_PER_ROW },
);
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const timeline = page.getByTestId("message-timeline");
await page.waitForFunction(() => {
const el = document.querySelector(
'[data-testid="message-timeline"]',
) as HTMLDivElement | null;
return !!el && el.querySelectorAll("[data-message-id]").length > 50;
});
await page.waitForTimeout(500);
// Scroll up off the bottom so the anchor is a mid-list row, not "at-bottom"
// (the at-bottom path corrects differently). Land roughly in the middle.
await timeline.evaluate((element) => {
const el = element as HTMLDivElement;
el.scrollTop = Math.floor(el.scrollHeight * 0.4);
el.dispatchEvent(new Event("scroll", { bubbles: true }));
});
await page.waitForTimeout(200);
const client = await page.context().newCDPSession(page);
await client.send("Performance.enable");
const before = await readMetrics(client);
const scrollTopBefore = await timeline.evaluate(
(el) => (el as HTMLDivElement).scrollTop,
);
// Prepend a small older batch through the live ingest path and time the tick
// that flushes the resulting React commit + layout + anchor restore.
const tickMs = await page.evaluate(async (count) => {
const t0 = performance.now();
window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__?.({
channelName: "general",
count,
lineCount: 3,
emit: true,
});
// Force a layout flush so the synchronous commit + anchor scrollBy + reflow
// are all accounted before we stop the clock. Two rAFs span the commit and
// the subsequent paint-prep tick.
await new Promise<void>((r) => requestAnimationFrame(() => r()));
await new Promise<void>((r) => requestAnimationFrame(() => r()));
return performance.now() - t0;
}, 10);
const after = await readMetrics(client);
const scrollTopAfter = await timeline.evaluate(
(el) => (el as HTMLDivElement).scrollTop,
);
const rowCountAfter = await timeline.evaluate(
(el) => (el as HTMLDivElement).querySelectorAll("[data-message-id]").length,
);
await client.send("Performance.disable");
const layoutDelta = after.layoutMs - before.layoutMs;
const recalcDelta = after.recalcMs - before.recalcMs;
const anchorDrift = scrollTopAfter - scrollTopBefore;
/* eslint-disable no-console */
console.log("\n=== PREPEND RE-RENDER COST (10 older rows, scrolled up) ===");
console.log(`rows after prepend (live DOM): ${rowCountAfter}`);
console.log(`tick wall-time (commit+layout): ${tickMs.toFixed(2)}ms`);
console.log(`layout time attributed: ${layoutDelta.toFixed(1)}ms`);
console.log(`style-recalc time attributed: ${recalcDelta.toFixed(1)}ms`);
console.log(
`anchor drift (scrollTop delta): ${anchorDrift.toFixed(1)}px (0 = held)`,
);
console.log("(tick > ~8ms during active wheel is where 120Hz stalls)");
console.log("===========================================================\n");
/* eslint-enable no-console */
// Instrument, not a gate — just confirm the prepend actually happened.
expect(rowCountAfter).toBeGreaterThan(50);
});

View File

@@ -96,6 +96,8 @@ type MockBridgeOptions = {
feedReadError?: string;
canvasReadError?: string;
openDmDelayMs?: number;
/** Delay (ms) for older-history fetches; see e2eBridge mock config. */
historyDelayMs?: number;
profileReadDelayMs?: number;
profileReadError?: string;
profileUpdateError?: string;