perf(timeline): gate heavy message render behind useDeferredValue (#1022)

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7f <ab176f059d100602ea25073d9a69ee9817f7b691c76a897180d48498d959faa2@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Taylor Ho
2026-06-15 15:23:33 -07:00
committed by GitHub
parent 116486592d
commit cbc754cffb
10 changed files with 701 additions and 31 deletions

View File

@@ -103,6 +103,114 @@ test("buildThreadPanelData keeps direct comments unindented", () => {
);
});
// Per-id stabilization: thread rows feed `MessageRow` a depth-normalized copy
// of each reply. When `timelineMessages` churns (typing/presence) but the
// reply objects survive by reference, rebuilding the thread panel must hand
// `MessageRow` the SAME normalized object reference so the row/markdown memo
// hits — instead of a fresh `{ ...reply, depth }` spread every render.
test("thread reply objects keep identity across unrelated timelineMessages churn", () => {
const root = message({ id: "root", createdAt: 1 });
const replyA = message({
id: "a",
createdAt: 2,
parentId: "root",
rootId: "root",
depth: 1,
tags: [["e", "root", "", "reply"]],
});
const replyB = message({
id: "b",
createdAt: 3,
parentId: "a",
rootId: "root",
depth: 2,
tags: [["e", "a", "", "reply"]],
});
// First render of the thread.
const first = buildThreadPanelData(
[root, replyA, replyB],
"root",
"root",
new Set(["a"]),
);
// An unrelated channel churn produces a NEW `timelineMessages` array, but the
// reply objects themselves are reused by reference (only their position in
// the surrounding array changed — e.g. a presence ping or typing indicator
// that the snapshot layer leaves the reply identities intact for).
const churned = [
message({ id: "noise", createdAt: 99 }),
root,
replyA,
replyB,
];
const second = buildThreadPanelData(churned, "root", "root", new Set(["a"]));
const firstById = new Map(
first.visibleReplies.map((entry) => [entry.message.id, entry.message]),
);
const secondById = new Map(
second.visibleReplies.map((entry) => [entry.message.id, entry.message]),
);
assert.ok(firstById.size > 0, "expected at least one visible reply");
for (const [id, normalized] of firstById) {
assert.strictEqual(
secondById.get(id),
normalized,
`normalized reply ${id} must be the SAME object reference across an unrelated churn (memo hit)`,
);
// Depth must still reach the row correctly via the cached object.
assert.equal(
typeof normalized.depth,
"number",
`normalized reply ${id} must carry a numeric depth`,
);
}
});
test("thread reply objects recompute when the source reply object is replaced", () => {
const root = message({ id: "root", createdAt: 1 });
const reply = message({
id: "a",
createdAt: 2,
parentId: "root",
rootId: "root",
depth: 1,
tags: [["e", "root", "", "reply"]],
});
const first = buildThreadPanelData([root, reply], "root", "root", new Set());
// A genuine edit/refresh: the reply is a brand-new object (new identity).
const editedReply = message({
id: "a",
createdAt: 2,
parentId: "root",
rootId: "root",
depth: 1,
body: "edited body",
tags: [["e", "root", "", "reply"]],
});
const second = buildThreadPanelData(
[root, editedReply],
"root",
"root",
new Set(),
);
const firstA = first.visibleReplies.find((e) => e.message.id === "a");
const secondA = second.visibleReplies.find((e) => e.message.id === "a");
assert.ok(firstA && secondA, "expected reply 'a' in both renders");
assert.notStrictEqual(
secondA.message,
firstA.message,
"a replaced source reply must produce a fresh normalized object",
);
assert.equal(secondA.message.body, "edited body");
});
test("buildThreadPanelDataFromIndex matches direct panel data", () => {
const root = message({ id: "root", createdAt: 1 });
const directComment = message({

View File

@@ -47,14 +47,47 @@ function normalizeHeadMessage(message: TimelineMessage): TimelineMessage {
};
}
// Thread rows feed `MessageRow` a depth-normalized copy of each reply. Building
// that copy fresh (`{ ...message, depth }`) on every render hands `MessageRow` a
// new object identity every time `timelineMessages` churns (typing/presence),
// even when the reply and its depth are byte-identical — which defeats the
// row/markdown memo and forces a ~1.4ms/row re-parse on threads where the main
// timeline (which passes the raw stable ref) stays cheap.
//
// Mirror the main list's per-id context memoization (`videoReviewContextById`):
// cache the normalized object keyed on the source reply identity + depth, so an
// unrelated channel churn that leaves a reply (and its tree position) intact
// reuses the exact same object reference and the memo hits.
//
// Keyed on the source `reply` reference via a WeakMap: a new `timelineMessages`
// set produces new reply objects (genuine recompute), and stale entries are
// collected automatically when the old message set is dropped.
const normalizedInlineReplyCache = new WeakMap<
TimelineMessage,
Map<number, TimelineMessage>
>();
function normalizeInlineReplyMessage(
message: TimelineMessage,
depth: number,
): TimelineMessage {
return {
let byDepth = normalizedInlineReplyCache.get(message);
if (!byDepth) {
byDepth = new Map<number, TimelineMessage>();
normalizedInlineReplyCache.set(message, byDepth);
}
const cached = byDepth.get(depth);
if (cached) {
return cached;
}
const normalized: TimelineMessage = {
...message,
depth,
};
byDepth.set(depth, normalized);
return normalized;
}
function buildDirectChildrenByParentId(messages: TimelineMessage[]) {

View File

@@ -0,0 +1,289 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
BOTTOM_THRESHOLD_PX,
buildDayGroupBoundaries,
isNearBottomMetrics,
resolveDeepLinkTarget,
selectDeferredListRenderState,
selectLatestMessageKey,
} from "./timelineSnapshot.ts";
// Local-midnight unix-second timestamps so isSameDay (local time) is stable
// regardless of the machine's timezone.
function dayAt(year, month, day, hour = 12) {
return Math.floor(
new Date(year, month - 1, day, hour, 0, 0).getTime() / 1_000,
);
}
function message(overrides) {
return {
id: "message",
renderKey: undefined,
createdAt: dayAt(2026, 6, 14),
pubkey: "author",
author: "Author",
avatarUrl: null,
role: undefined,
personaDisplayName: undefined,
time: "12:00 PM",
body: "body",
parentId: null,
rootId: null,
depth: 0,
accent: false,
pending: undefined,
edited: false,
kind: 9,
tags: [],
reactions: undefined,
...overrides,
};
}
// --- sticky-bottom autoscroll -------------------------------------------------
test("isNearBottomMetrics: true when within threshold of the bottom", () => {
// distance = scrollHeight - clientHeight - scrollTop = 1000 - 600 - 380 = 20
assert.equal(
isNearBottomMetrics({
scrollHeight: 1_000,
clientHeight: 600,
scrollTop: 380,
}),
true,
);
});
test("isNearBottomMetrics: true exactly at the threshold boundary", () => {
const scrollTop = 1_000 - 600 - BOTTOM_THRESHOLD_PX; // distance === threshold
assert.equal(
isNearBottomMetrics({ scrollHeight: 1_000, clientHeight: 600, scrollTop }),
true,
);
});
test("isNearBottomMetrics: false when scrolled up beyond the threshold", () => {
// distance = 1000 - 600 - 100 = 300 > 72
assert.equal(
isNearBottomMetrics({
scrollHeight: 1_000,
clientHeight: 600,
scrollTop: 100,
}),
false,
);
});
test("selectLatestMessageKey: prefers renderKey, falls back to id, undefined when empty", () => {
assert.equal(selectLatestMessageKey([]), undefined);
assert.equal(
selectLatestMessageKey([message({ id: "a" }), message({ id: "b" })]),
"b",
);
assert.equal(
selectLatestMessageKey([message({ id: "b", renderKey: "local-b" })]),
"local-b",
);
});
test("selectLatestMessageKey: detects a newly arrived latest message", () => {
const before = [message({ id: "a" }), message({ id: "b" })];
const after = [
...before,
message({ id: "c", createdAt: dayAt(2026, 6, 14, 13) }),
];
assert.notEqual(
selectLatestMessageKey(before),
selectLatestMessageKey(after),
);
});
// --- day dividers -------------------------------------------------------------
test("buildDayGroupBoundaries: empty snapshot has no groups", () => {
assert.deepEqual(buildDayGroupBoundaries([]), []);
});
test("buildDayGroupBoundaries: messages on one day form a single group", () => {
const messages = [
message({ id: "a", createdAt: dayAt(2026, 6, 14, 9) }),
message({ id: "b", createdAt: dayAt(2026, 6, 14, 10) }),
message({ id: "c", createdAt: dayAt(2026, 6, 14, 23) }),
];
const groups = buildDayGroupBoundaries(messages);
assert.equal(groups.length, 1);
assert.deepEqual(
{ startIndex: groups[0].startIndex, count: groups[0].count },
{ startIndex: 0, count: 3 },
);
});
test("buildDayGroupBoundaries: a day boundary opens a new group", () => {
const messages = [
message({ id: "a", createdAt: dayAt(2026, 6, 13, 22) }),
message({ id: "b", createdAt: dayAt(2026, 6, 14, 1) }),
message({ id: "c", createdAt: dayAt(2026, 6, 14, 2) }),
message({ id: "d", createdAt: dayAt(2026, 6, 15, 8) }),
];
const groups = buildDayGroupBoundaries(messages);
assert.deepEqual(
groups.map((g) => ({ startIndex: g.startIndex, count: g.count })),
[
{ startIndex: 0, count: 1 },
{ startIndex: 1, count: 2 },
{ startIndex: 3, count: 1 },
],
);
});
test("buildDayGroupBoundaries: group counts always sum to message count", () => {
const messages = [
message({ id: "a", createdAt: dayAt(2026, 6, 13) }),
message({ id: "b", createdAt: dayAt(2026, 6, 14) }),
message({ id: "c", createdAt: dayAt(2026, 6, 14) }),
];
const total = buildDayGroupBoundaries(messages).reduce(
(n, g) => n + g.count,
0,
);
assert.equal(total, messages.length);
});
// --- jump-to-message deep links ----------------------------------------------
test("resolveDeepLinkTarget: unresolved with no target", () => {
const messages = [message({ id: "a" })];
assert.deepEqual(resolveDeepLinkTarget(messages, null), {
resolved: false,
index: -1,
});
assert.deepEqual(resolveDeepLinkTarget(messages, undefined), {
resolved: false,
index: -1,
});
});
test("resolveDeepLinkTarget: resolves a present target to its index", () => {
const messages = [
message({ id: "a" }),
message({ id: "b" }),
message({ id: "c" }),
];
assert.deepEqual(resolveDeepLinkTarget(messages, "b"), {
resolved: true,
index: 1,
});
});
test("resolveDeepLinkTarget: unresolved when the target is not in the snapshot", () => {
const messages = [message({ id: "a" }), message({ id: "b" })];
assert.deepEqual(resolveDeepLinkTarget(messages, "missing"), {
resolved: false,
index: -1,
});
});
// --- shared-snapshot / no-tearing guarantee ----------------------------------
//
// All three must-keep decisions must read off the SAME snapshot. If the deep-link
// decision reads a fresh snapshot while the rendered list / scroll math still
// read a stale one, the jump fires against a row that hasn't committed and
// silently fails.
test("no-tearing: a target only in the fresh snapshot does NOT resolve against the stale one", () => {
const stale = [message({ id: "a" }), message({ id: "b" })];
const fresh = [
...stale,
message({ id: "target", createdAt: dayAt(2026, 6, 15) }),
];
// Reading the deep link against the stale snapshot (what the painted DOM
// still shows) must report unresolved — you can't scroll to an uncommitted row.
assert.equal(resolveDeepLinkTarget(stale, "target").resolved, false);
// Against the fresh snapshot it resolves — proving the gate is which snapshot
// you feed, not the function.
assert.equal(resolveDeepLinkTarget(fresh, "target").resolved, true);
});
test("no-tearing: all three decisions agree when fed one shared snapshot", () => {
const snapshot = [
message({ id: "a", createdAt: dayAt(2026, 6, 13) }),
message({ id: "b", createdAt: dayAt(2026, 6, 14) }),
message({
id: "target",
renderKey: "rk-target",
createdAt: dayAt(2026, 6, 14, 18),
}),
];
// Deep link resolves to the last index...
const link = resolveDeepLinkTarget(snapshot, "target");
// ...the day grouping covers that same index...
const groups = buildDayGroupBoundaries(snapshot);
const coveredCount = groups.reduce((n, g) => n + g.count, 0);
// ...and the sticky-bottom latest-key points at that same final message.
const latestKey = selectLatestMessageKey(snapshot);
assert.equal(link.index, snapshot.length - 1);
assert.equal(coveredCount, snapshot.length);
assert.equal(latestKey, snapshot[snapshot.length - 1].renderKey);
});
test("no-tearing: stale snapshot keeps all three decisions internally consistent", () => {
// Feeding the stale list everywhere keeps the decisions consistent with each
// other — none of them see the uncommitted row.
const stale = [
message({ id: "a", createdAt: dayAt(2026, 6, 14, 9) }),
message({ id: "b", createdAt: dayAt(2026, 6, 14, 10) }),
];
const link = resolveDeepLinkTarget(stale, "target");
const coveredCount = buildDayGroupBoundaries(stale).reduce(
(n, g) => n + g.count,
0,
);
const latestKey = selectLatestMessageKey(stale);
assert.equal(link.resolved, false);
assert.equal(coveredCount, stale.length);
assert.equal(latestKey, "b");
});
// --- deferred reply-list render state (thread side pane) --------------------
//
// When MessageThreadPanel gates its reply render behind useDeferredValue, the
// painted (deferred) snapshot lags the live one for a frame. selectDeferredList
// RenderState picks which of three states the reply region paints so we never
// flash "No replies" over a list that's streaming in on the deferred commit.
test("deferred-render: paints the list whenever the deferred snapshot has rows", () => {
// deferred caught up — normal steady state.
assert.equal(selectDeferredListRenderState(3, 3), "list");
// deferred still showing the OLD non-empty list while a new one streams in;
// we keep painting rows (no flash) — the dim-pending styling handles the lag.
assert.equal(selectDeferredListRenderState(2, 5), "list");
});
test("deferred-render: empty state only when the LIVE list is genuinely empty", () => {
// Both empty — the thread truly has no replies.
assert.equal(selectDeferredListRenderState(0, 0), "empty");
});
test("deferred-render: pending when deferred is empty but live has content", () => {
// Deferred snapshot hasn't committed the rows yet but the live list is
// non-empty. Must NOT report "empty" — that would flash the "No replies"
// affordance for a frame on thread-open.
assert.equal(selectDeferredListRenderState(0, 4), "pending");
assert.notEqual(selectDeferredListRenderState(0, 4), "empty");
});
test("deferred-render: keys the empty decision off the live count, not deferred", () => {
// Same deferred count (0), opposite verdicts — proving the live count is the
// tie-breaker. This is the no-tearing guarantee for the empty affordance:
// the empty state is a function of the LIVE list, never the lagging one.
assert.equal(selectDeferredListRenderState(0, 0), "empty");
assert.equal(selectDeferredListRenderState(0, 1), "pending");
});

View File

@@ -0,0 +1,150 @@
/**
* Pure helpers that read a timeline message snapshot to compute the values the
* timeline render needs: sticky-bottom autoscroll, day dividers, jump-to-message
* deep links, and the deferred reply-list render state.
*
* Keeping these out of the component render body / scroll-manager effects lets
* them be covered by the lib-level `*.test.mjs` suite. It also enforces the key
* correctness property: every decision must read off the SAME snapshot. If the
* deep-link lookup reads a fresher list than the rows the DOM has actually
* committed, a jump fires against a row that isn't there yet and silently fails.
*/
import type { TimelineMessage } from "@/features/messages/types";
import { isSameDay } from "./dateFormatters";
/** Distance (px) from the bottom within which the timeline counts as "at bottom". */
export const BOTTOM_THRESHOLD_PX = 72;
/** Minimal scroll geometry the sticky-bottom decision needs — a pure subset of the DOM element. */
export type ScrollMetrics = {
scrollHeight: number;
clientHeight: number;
scrollTop: number;
};
/**
* Is the timeline scrolled close enough to the bottom to count as "at bottom"?
* Pure over geometry so the threshold math is testable without a DOM.
*/
export function isNearBottomMetrics(metrics: ScrollMetrics): boolean {
return (
metrics.scrollHeight - metrics.clientHeight - metrics.scrollTop <=
BOTTOM_THRESHOLD_PX
);
}
/** Reads live scroll geometry off a container and applies the bottom-threshold rule. */
export function isNearBottom(container: HTMLDivElement): boolean {
return isNearBottomMetrics({
scrollHeight: container.scrollHeight,
clientHeight: container.clientHeight,
scrollTop: container.scrollTop,
});
}
/**
* Identity of the last message in a snapshot, used to detect "a new latest
* message arrived" for autoscroll. Prefers `renderKey` (stable across optimistic
* send-ack) and falls back to `id`. Returns `undefined` for an empty snapshot.
*/
export function selectLatestMessageKey(
messages: readonly TimelineMessage[],
): string | undefined {
if (messages.length === 0) {
return undefined;
}
const latest = messages[messages.length - 1];
return latest.renderKey ?? latest.id;
}
/** 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. */
key: string;
/** Index into `messages` of the first message in this day. */
startIndex: number;
/** Number of messages in this day group. */
count: number;
/** The `createdAt` (unix seconds) used to render the heading label. */
headingTimestamp: number;
};
/**
* Walks a snapshot in order and produces the day-group boundaries. A new group
* starts at index 0 and whenever a message falls on a different calendar day
* than the one before it.
*/
export function buildDayGroupBoundaries(
messages: readonly TimelineMessage[],
): DayGroupBoundary[] {
const boundaries: DayGroupBoundary[] = [];
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
const prev = i > 0 ? messages[i - 1] : null;
if (!prev || !isSameDay(prev.createdAt, message.createdAt)) {
boundaries.push({
key: `day-${message.createdAt}`,
startIndex: i,
count: 1,
headingTimestamp: message.createdAt,
});
} else {
boundaries[boundaries.length - 1].count += 1;
}
}
return boundaries;
}
/** Outcome of resolving a deep-link target against the current snapshot. */
export type DeepLinkResolution = {
/** Whether the target message exists in this snapshot (i.e. a row would be committed). */
resolved: boolean;
/** Index of the target in `messages`, or -1 when unresolved. */
index: number;
};
/**
* Does a jump-to-message target resolve against THIS snapshot? The scroll-manager
* effect only does `querySelector` + `scrollIntoView` once a target row is
* actually committed, so the jump must read the same snapshot the list rendered
* — otherwise it scrolls to a row that isn't there yet.
*/
export function resolveDeepLinkTarget(
messages: readonly TimelineMessage[],
targetMessageId: string | null | undefined,
): DeepLinkResolution {
if (!targetMessageId) {
return { resolved: false, index: -1 };
}
const index = messages.findIndex((message) => message.id === targetMessageId);
return { resolved: index !== -1, index };
}
/**
* Which of three states a deferred list should paint. A list gated behind
* `useDeferredValue` lags the live one for a frame, so the deferred snapshot can
* be empty while the live list is not. Keying the empty state off the LIVE count
* stops us flashing an "empty" affordance over a list that's streaming in:
*
* - "list" → the deferred snapshot has rows; paint them
* - "empty" → the LIVE list is genuinely empty; paint the empty state
* - "pending" → deferred is empty but live has content; paint nothing yet
*/
export type DeferredListRenderState = "list" | "empty" | "pending";
export function selectDeferredListRenderState(
deferredCount: number,
liveCount: number,
): DeferredListRenderState {
if (deferredCount > 0) {
return "list";
}
if (liveCount === 0) {
return "empty";
}
return "pending";
}

View File

@@ -31,6 +31,7 @@ import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow";
import { TypingIndicatorRow } from "./TypingIndicatorRow";
import { useComposerHeightPadding } from "./useComposerHeightPadding";
import { useTimelineScrollManager } from "./useTimelineScrollManager";
import { selectDeferredListRenderState } from "@/features/messages/lib/timelineSnapshot";
type MessageThreadPanelProps = {
agentPubkeys?: ReadonlySet<string>;
@@ -83,6 +84,12 @@ type MessageThreadPanelProps = {
onUnfollowThread?: () => void;
};
/** Stable empty reference used as the `useDeferredValue` initial value so the
* first render when a thread opens stays light instead of blocking on the full
* reply list. Must be module-level so its identity never changes. Mirrors
* `EMPTY_MESSAGES` in MessageTimeline. */
const EMPTY_THREAD_REPLIES: MainTimelineEntry[] = [];
type MessageThreadPanelSkeletonProps = {
isSinglePanelView?: boolean;
layout?: "standalone" | "split";
@@ -314,9 +321,35 @@ export function MessageThreadPanel({
}
: null;
// The thread side pane renders its reply list straight into heavy
// `react-markdown` rows (`MessageRow`), so opening a deep thread would block
// the main thread and the OS would show the busy cursor. Gate the reply render
// behind `useDeferredValue`. `initialValue: []` keeps even the FIRST render on
// thread-open light; the heavy list streams in on a deferred, interruptible
// commit. We deliberately drive BOTH the scroll manager and the rendered list
// off the SAME deferred value — sticky-bottom / deep-link logic reads the DOM
// (`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.
const deferredThreadReplies = React.useDeferredValue(
threadReplies,
EMPTY_THREAD_REPLIES,
);
const isRepliesPending = deferredThreadReplies !== threadReplies;
// Which of the three states the reply region paints this frame. Delegated to
// a pure helper so the "don't flash empty over an incoming list" rule is
// covered in the lib test suite (see selectDeferredListRenderState).
const repliesRenderState = selectDeferredListRenderState(
deferredThreadReplies.length,
threadReplies.length,
);
const threadMessages = React.useMemo(
() => threadReplies.map((entry) => entry.message),
[threadReplies],
() => deferredThreadReplies.map((entry) => entry.message),
[deferredThreadReplies],
);
const {
@@ -385,9 +418,19 @@ export function MessageThreadPanel({
</div>
<div className="px-3 pb-3 pt-1" data-testid="message-thread-replies">
{threadReplies.length > 0 ? (
<div className="space-y-2.5">
{threadReplies.map((entry) => {
{repliesRenderState === "list" ? (
<div
className={cn(
"space-y-2.5",
// While a deferred render is in flight the painted reply list
// lags the latest `threadReplies`. Dim it slightly so the
// streaming-in reads as intentional instead of frozen — mirrors
// the main timeline.
isRepliesPending && "opacity-60 transition-opacity",
)}
data-render-pending={isRepliesPending ? "true" : undefined}
>
{deferredThreadReplies.map((entry) => {
return (
<div
className={cn(
@@ -431,7 +474,10 @@ export function MessageThreadPanel({
);
})}
</div>
) : (
) : repliesRenderState === "empty" ? (
// Only show the empty state when the thread is GENUINELY empty.
// Keying off `deferredThreadReplies` would flash "No replies" for a
// frame while a non-empty list streams in on the deferred commit.
<div className="rounded-2xl border border-dashed border-border/70 bg-card/40 px-4 py-6 text-center">
<p className="text-sm font-medium text-foreground/80">
No replies in this branch yet
@@ -440,7 +486,10 @@ export function MessageThreadPanel({
Reply in the thread to continue this branch.
</p>
</div>
)}
) : // "pending": deferred list is empty but the live list has content —
// 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>

View File

@@ -90,6 +90,11 @@ type ChannelIntro = {
icon?: React.ReactNode;
};
/** Stable empty reference used as the `useDeferredValue` initial value so the
* first render on channel entry stays light instead of blocking on the full
* message list. Must be module-level so its identity never changes. */
const EMPTY_MESSAGES: TimelineMessage[] = [];
type DirectMessageIntroParticipant = {
avatarUrl: string | null;
displayName: string;
@@ -135,6 +140,20 @@ export const MessageTimeline = React.memo(function MessageTimeline({
const internalScrollRef = React.useRef<HTMLDivElement>(null);
const scrollContainerRef = externalScrollRef ?? internalScrollRef;
const topSentinelRef = React.useRef<HTMLDivElement>(null);
// Gate the heavy timeline render (each row runs a synchronous
// react-markdown parse) behind React concurrency. `useDeferredValue` lets the
// commit that rebuilds the message list yield to higher-priority work, so the
// main thread stops freezing and the OS no longer shows the busy cursor when
// entering a channel. We pass `initialValue: []` so even the FIRST render on
// channel entry stays light — the heavy list streams in on a deferred commit
// rather than blocking the initial paint. We deliberately drive BOTH the
// scroll manager and the rendered list off the same deferred value —
// scroll/autoscroll/deep-link logic reads the DOM (`scrollIntoView`,
// ResizeObserver on the content), so it must stay consistent with what's
// actually painted. You can't scroll to a row that hasn't committed yet.
const deferredMessages = React.useDeferredValue(messages, EMPTY_MESSAGES);
const isRenderPending = deferredMessages !== messages;
const scrollRestorationId = targetMessageId
? `message-timeline:${channelId ?? "none"}:target:${targetMessageId}`
: `message-timeline:${channelId ?? "none"}`;
@@ -151,7 +170,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
} = useTimelineScrollManager({
channelId,
isLoading,
messages,
messages: deferredMessages,
onTargetReached,
scrollContainerRef,
targetMessageId,
@@ -196,10 +215,10 @@ export const MessageTimeline = React.memo(function MessageTimeline({
const showIntro = showDirectMessageIntro || showChannelIntro;
const showGenericEmpty =
!isLoading &&
messages.length === 0 &&
deferredMessages.length === 0 &&
directMessageIntro === null &&
channelIntro === null;
const showMessageList = !isLoading && messages.length > 0;
const showMessageList = !isLoading && deferredMessages.length > 0;
const timelineSkeletonRows = useTimelineSkeletonRows({
channelId,
isLoading,
@@ -378,7 +397,15 @@ export const MessageTimeline = React.memo(function MessageTimeline({
{showMessageList ? (
<div
className={cn("flex flex-col gap-2", !showIntro && "mt-auto")}
className={cn(
"flex flex-col gap-2",
!showIntro && "mt-auto",
// While a deferred render is in flight the painted
// list lags the latest `messages`. Dim it slightly so the
// streaming-in feels intentional instead of frozen.
isRenderPending && "opacity-60 transition-opacity",
)}
data-render-pending={isRenderPending ? "true" : undefined}
>
<TimelineMessageList
agentPubkeys={agentPubkeys}
@@ -390,7 +417,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
highlightedMessageId={highlightedMessageId}
isFollowingThreadById={isFollowingThreadById}
messageFooters={messageFooters}
messages={messages}
messages={deferredMessages}
onDelete={onDelete}
onEdit={onEdit}
onMarkUnread={onMarkUnread}

View File

@@ -1,14 +1,12 @@
import * as React from "react";
import {
formatDayHeading,
isSameDay,
} from "@/features/messages/lib/dateFormatters";
import { formatDayHeading } from "@/features/messages/lib/dateFormatters";
import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel";
import {
buildVideoReviewCommentsByRootId,
buildVideoReviewContextForMessage,
} 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";
@@ -137,12 +135,21 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
}> = [];
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 prev = i > 0 ? entries[i - 1]?.message : null;
const messageRenderKey = message.renderKey ?? message.id;
if (!prev || !isSameDay(prev.createdAt, message.createdAt)) {
if (dayGroupStartIndices.has(i)) {
currentDayGroup = {
key: `day-${message.createdAt}`,
label: formatDayHeading(message.createdAt),

View File

@@ -1,8 +0,0 @@
const BOTTOM_THRESHOLD_PX = 72;
export function isNearBottom(container: HTMLDivElement) {
return (
container.scrollHeight - container.clientHeight - container.scrollTop <=
BOTTOM_THRESHOLD_PX
);
}

View File

@@ -1,7 +1,11 @@
import * as React from "react";
import {
isNearBottom,
resolveDeepLinkTarget,
selectLatestMessageKey,
} from "@/features/messages/lib/timelineSnapshot";
import type { TimelineMessage } from "@/features/messages/types";
import { isNearBottom } from "./messageTimelineUtils";
type UseTimelineScrollManagerOptions = {
channelId?: string | null;
@@ -97,9 +101,7 @@ export function useTimelineScrollManager({
const latestMessage =
messages.length > 0 ? messages[messages.length - 1] : undefined;
const latestMessageKey = latestMessage
? (latestMessage.renderKey ?? latestMessage.id)
: undefined;
const latestMessageKey = selectLatestMessageKey(messages);
// biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref passed from the parent — its identity never changes
const syncScrollState = React.useCallback(() => {
@@ -356,6 +358,15 @@ export function useTimelineScrollManager({
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;
}
const timeline = timelineRef.current;
if (!timeline) {
return;

View File

@@ -16,6 +16,10 @@ const SELF_PROFILE_CACHE_KEY = `buzz-self-profile.v1:${MOCK_RELAY_URL}:${MOCK_PU
async function settle(page: import("@playwright/test").Page) {
await page.evaluate(() =>
// Tolerate cancelled animations: a SkeletonReveal animation cancelled
// mid-flight (skeleton → live content swap) rejects `.finished` with an
// AbortError. allSettled lets the animations that DO finish settle instead
// of aborting the whole wait on the first cancel.
Promise.allSettled(document.getAnimations().map((a) => a.finished)),
);
}