feat(desktop): re-land virtualized timeline to fix macOS beachball (#1250)

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1067mpdna4vy22u9eltmmhtdfw56kww6ve2aze025dtfm4pxg07nqztjfhm <7ebdb0b67dab08a570b9faf7bbada97535673b4ccaba2cbd546ad3ba84c87fa6@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Will Pfleger
2026-06-26 14:09:07 -04:00
committed by GitHub
parent 1a61d783ad
commit 8c3d0c92e8
19 changed files with 2647 additions and 527 deletions

View File

@@ -669,9 +669,6 @@ export const ChannelPane = React.memo(function ChannelPane({
hasComposerOverlay={hasMainComposerOverlay}
hasOlderMessages={hasOlderMessages}
isFetchingOlder={isFetchingOlder}
layoutShiftKey={
useSplitAuxiliaryPane ? (openThreadHeadId ?? "closed") : "overlay"
}
isFollowingThreadById={isFollowingThreadById}
isMessageUnreadById={isMessageUnreadById}
personaLookup={personaLookup}

View File

@@ -0,0 +1,210 @@
import assert from "node:assert/strict";
import test from "node:test";
import { CONVERGENCE_FRAME_CAP, convergenceStep } from "./scrollConvergence.ts";
function input(overrides) {
return {
targetMessageId: "target",
indexByMessageId: new Map([["target", 100]]),
lastIssuedIndex: null,
librarySettled: false,
stalledOffTarget: false,
framesUsed: 0,
...overrides,
};
}
// --- re-aim / staleness guard ------------------------------------------------
test("convergenceStep: first frame aims at the resolved index, not yet done", () => {
const step = convergenceStep(input({ lastIssuedIndex: null }));
assert.equal(step.nextIndex, 100);
assert.equal(step.done, false);
assert.equal(step.converged, false);
});
test("convergenceStep: re-resolves a shifted index from the map each frame", () => {
// A prepend shifted the target from 100 to 105. The library is still chasing
// the old index (lastIssuedIndex 100); the reducer must aim at the NEW index
// so the adapter re-issues scrollToIndex(105). This is the staleness guard.
const step = convergenceStep(
input({
indexByMessageId: new Map([["target", 105]]),
lastIssuedIndex: 100,
}),
);
assert.equal(step.nextIndex, 105);
assert.equal(step.done, false);
assert.equal(step.converged, false);
});
test("convergenceStep: target removed mid-settle stops with converged=false", () => {
// Target deleted from the map while the loop was chasing it. Terminate so the
// adapter clears the highlight instead of chasing a vanished row.
const step = convergenceStep(
input({
indexByMessageId: new Map(), // target gone
lastIssuedIndex: 100,
framesUsed: 3,
}),
);
assert.equal(step.nextIndex, null);
assert.equal(step.done, true);
assert.equal(step.converged, false);
});
// --- settle ------------------------------------------------------------------
test("convergenceStep: library settled while aiming at current index converges", () => {
const step = convergenceStep(
input({ lastIssuedIndex: 100, librarySettled: true }),
);
assert.equal(step.nextIndex, 100);
assert.equal(step.done, true);
assert.equal(step.converged, true);
});
test("convergenceStep: a settle reported WHILE re-aiming is ignored", () => {
// The index just moved (105) but the library reports settled — that settle is
// on the OLD index (100), so it must NOT count as convergence. The reducer
// keeps going and aims at the new index.
const step = convergenceStep(
input({
indexByMessageId: new Map([["target", 105]]),
lastIssuedIndex: 100,
librarySettled: true,
}),
);
assert.equal(step.nextIndex, 105);
assert.equal(step.done, false);
assert.equal(step.converged, false);
});
test("convergenceStep: aiming at current but not yet settled keeps waiting", () => {
// Library is chasing the right index but its offset hasn't stabilized. The
// reducer returns the same index (so the adapter re-issues NOTHING — issuing
// would reset the library's stableFrames and prevent settling) and waits.
const step = convergenceStep(
input({ lastIssuedIndex: 100, librarySettled: false }),
);
assert.equal(step.nextIndex, 100);
assert.equal(step.reissue, false);
assert.equal(step.done, false);
assert.equal(step.converged, false);
});
// --- off-target stall (liveness) ---------------------------------------------
test("convergenceStep: stalled off-target while aiming at current re-issues same index", () => {
// The library's offset stopped moving but never reached the current index's
// target (its internal reconcile deadlocked after rows re-measured). The
// reducer signals a same-index re-issue to kick it — the loop continues.
const step = convergenceStep(
input({ lastIssuedIndex: 100, stalledOffTarget: true }),
);
assert.equal(step.nextIndex, 100);
assert.equal(step.reissue, true);
assert.equal(step.done, false);
assert.equal(step.converged, false);
});
test("convergenceStep: a stall reported WHILE re-aiming does not re-issue", () => {
// The index just moved (105) but the library reports a stall on the OLD index
// (100). The reducer re-aims at the new index normally; the stale stall must
// NOT trigger a same-index kick (there is no current-index stall to kick).
const step = convergenceStep(
input({
indexByMessageId: new Map([["target", 105]]),
lastIssuedIndex: 100,
stalledOffTarget: true,
}),
);
assert.equal(step.nextIndex, 105);
assert.equal(step.reissue, false);
assert.equal(step.done, false);
assert.equal(step.converged, false);
});
test("convergenceStep: a settle takes priority over a concurrent stall flag", () => {
// Defensive: the adapter computes settle and stall as mutually exclusive, but
// if both arrive, a genuine settle must win (converge) rather than spin on a
// pointless re-issue.
const step = convergenceStep(
input({
lastIssuedIndex: 100,
librarySettled: true,
stalledOffTarget: true,
}),
);
assert.equal(step.done, true);
assert.equal(step.converged, true);
assert.equal(step.reissue, false);
});
// --- frame cap ---------------------------------------------------------------
test("convergenceStep: terminates at the frame cap without converging", () => {
// A row that never settles (librarySettled stays false) must still stop at the
// cap rather than spin forever.
const step = convergenceStep(
input({
lastIssuedIndex: 100,
librarySettled: false,
framesUsed: CONVERGENCE_FRAME_CAP - 1,
}),
);
assert.equal(step.done, true);
assert.equal(step.converged, false);
assert.equal(step.nextIndex, 100);
});
test("convergenceStep: frame cap bounds a perpetually shifting target", () => {
// Drive the loop the way the adapter would: the target index moves every
// frame, so the library never settles. The loop must terminate at the cap.
let lastIssuedIndex = null;
let framesUsed = 0;
let done = false;
let converged = true;
while (framesUsed < CONVERGENCE_FRAME_CAP + 5) {
const movingIndex = 100 + framesUsed; // shifts every frame
const step = convergenceStep(
input({
indexByMessageId: new Map([["target", movingIndex]]),
lastIssuedIndex,
librarySettled: false,
framesUsed,
}),
);
lastIssuedIndex = step.nextIndex;
framesUsed += 1;
if (step.done) {
done = step.done;
converged = step.converged;
break;
}
}
assert.equal(done, true);
assert.equal(converged, false);
assert.ok(framesUsed <= CONVERGENCE_FRAME_CAP);
});
test("convergenceStep: converges once a re-aimed index then settles", () => {
// Realistic flow: frame 0 aims (lastIssued null -> 100), frame 1 the library
// is chasing 100 and reports settled -> converged.
const aim = convergenceStep(input({ lastIssuedIndex: null }));
assert.equal(aim.nextIndex, 100);
assert.equal(aim.done, false);
const settle = convergenceStep(
input({
lastIssuedIndex: aim.nextIndex,
librarySettled: true,
framesUsed: 1,
}),
);
assert.equal(settle.done, true);
assert.equal(settle.converged, true);
});

View File

@@ -0,0 +1,151 @@
/**
* Pure staleness + termination decision for scrolling a virtualized timeline to
* a message that may be far off-screen.
*
* @tanstack/react-virtual already owns the OFFSET convergence: a single
* `scrollToIndex(index)` captures that index in `scrollState`, and its internal
* `reconcileScroll` rAF loop re-runs `getOffsetForIndex(index)` every frame —
* re-aiming as off-screen rows mount and `measureElement` corrects their
* heights — until the offset is stable (or a 5s safety valve fires). We do NOT
* recompute offsets; duplicating `getOffsetForIndex` against the library's own
* `measurementsCache`/`scrollMargin`/`scrollPadding` would only drift.
*
* What the library does NOT do: it chases the INDEX captured at call time, with
* no concept of a message id. If the data shifts mid-settle — a prepend or a
* delete above the target — the captured index now points at the wrong row and
* the library happily settles on it. This reducer owns exactly that gap: each
* frame it re-resolves the target's CURRENT index from the live map and decides
* whether the adapter must re-aim the library, let it settle, or stop.
*
* Two correctness properties this enforces and the `.mjs` suite gates:
* - The target index is re-resolved by id every frame (never frozen), so a
* concurrent prepend/delete that shifts the target re-aims the library at the
* new index instead of stranding it on the old one.
* - If the target id leaves the data mid-settle (deleted), the loop terminates
* with `converged: false` rather than chasing a vanished row to the cap.
*
* Plus one liveness property: a large windowed-out jump can leave the library's
* own reconcile deadlocked off-target (offset stable but short of the target
* after rows re-measured under it). When that happens the reducer signals a
* same-index re-issue (`reissue: true`) to restart the library's reconcile —
* the single case where re-issuing an unchanged index is correct.
*/
/** Where a scroll target should land in the viewport. Mirrors the library's align. */
export type ConvergenceAlign = "start" | "center" | "end";
export type ConvergenceInput = {
/** Id of the message to settle on — re-resolved against the map each frame. */
targetMessageId: string;
/** Live message-id -> item-index map; re-read every frame (staleness guard). */
indexByMessageId: Map<string, number>;
/**
* Index the library is currently chasing (the last index the adapter issued
* via `scrollToIndex`), or `null` before the first issue. Lets the reducer
* tell a re-aim (index moved) from a steady settle (index unchanged).
*/
lastIssuedIndex: number | null;
/**
* Whether the library's offset reached the current index's target this frame
* and stopped moving. Only meaningful once the library is chasing the CURRENT
* index; a settle reported while re-aiming is ignored.
*/
librarySettled: boolean;
/**
* Whether the library's offset has stopped moving but is NOT at the current
* index's target — it stalled mid-reconcile (its internal re-aim deadlocked
* after rows re-measured under it). The reducer kicks it with a fresh re-issue
* at the same index. Mutually exclusive with `librarySettled`.
*/
stalledOffTarget: boolean;
/** Frames already spent in the loop (the adapter increments per rAF). */
framesUsed: number;
};
export type ConvergenceDecision = {
/**
* Index the adapter should be aiming the library at, or `null` when the
* target is gone. The adapter only re-issues `scrollToIndex` when this differs
* from `lastIssuedIndex`, so a steady settle issues no redundant scroll (which
* would reset the library's `stableFrames` and prevent it from ever settling).
*/
nextIndex: number | null;
/**
* True when the adapter must re-issue `scrollToIndex(nextIndex)` even though
* the index is unchanged — used to kick the library out of an off-target
* stall. A normal steady settle leaves this false so no redundant scroll
* resets the library's `stableFrames`.
*/
reissue: boolean;
/** True once the loop must stop (settled, target gone, or frame cap hit). */
done: boolean;
/** True only when the loop stopped because the target row actually settled. */
converged: boolean;
};
/**
* Hard cap on frames so a perpetually re-measuring row, or a target whose index
* keeps shifting, can't spin the loop forever. The library has its own 5s valve;
* this is the adapter-side bound expressed in frames for deterministic testing.
*/
export const CONVERGENCE_FRAME_CAP = 32;
/**
* One frame of the convergence loop. Pure: given the live map and the library's
* settle state, decides the index to aim at and whether to stop.
*/
export function convergenceStep(input: ConvergenceInput): ConvergenceDecision {
const currentIndex = input.indexByMessageId.get(input.targetMessageId);
// Target left the data mid-settle (deleted) — stop without converging so the
// adapter clears the highlight instead of chasing a vanished row.
if (currentIndex === undefined) {
return { nextIndex: null, reissue: false, done: true, converged: false };
}
const aimingAtCurrent = input.lastIssuedIndex === currentIndex;
// The library only settles meaningfully once it is chasing the CURRENT index.
// A settle reported while we are still re-aiming (index just moved) is stale.
if (aimingAtCurrent && input.librarySettled) {
return {
nextIndex: currentIndex,
reissue: false,
done: true,
converged: true,
};
}
// Frame cap: accept the best index we have rather than spin forever on a row
// whose height never settles or a target whose index keeps shifting.
if (input.framesUsed + 1 >= CONVERGENCE_FRAME_CAP) {
return {
nextIndex: currentIndex,
reissue: false,
done: true,
converged: false,
};
}
// Library stalled off-target: its offset stopped moving but never reached the
// current index (its internal reconcile deadlocked after rows re-measured).
// Re-issue the SAME index to restart its reconcile — the one case where a
// same-index re-issue is correct rather than a stableFrames-resetting bug.
if (aimingAtCurrent && input.stalledOffTarget) {
return {
nextIndex: currentIndex,
reissue: true,
done: false,
converged: false,
};
}
// Either the index moved (adapter will re-issue scrollToIndex) or the library
// is still settling on the current index (adapter issues nothing, just waits).
return {
nextIndex: currentIndex,
reissue: false,
done: false,
converged: false,
};
}

View File

@@ -0,0 +1,231 @@
import assert from "node:assert/strict";
import test from "node:test";
import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";
import {
buildTimelineItems,
getTimelineItemKey,
resolveActiveDayTimestamp,
} from "./timelineItems.ts";
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: "m",
renderKey: undefined,
createdAt: dayAt(2026, 6, 14),
pubkey: "author",
parentId: null,
rootId: null,
depth: 0,
kind: 9,
tags: [],
...overrides,
};
}
// The builder takes MainTimelineEntry[] (post top-level filter); summary is
// irrelevant to item/divider placement, so null is fine here.
function entry(overrides) {
return { message: message(overrides), summary: null };
}
function kinds(items) {
return items.map((item) => item.kind);
}
// --- divider placement -------------------------------------------------------
test("buildTimelineItems: 3-day channel with unread mid-day-2 places dividers by index", () => {
const entries = [
entry({ id: "d1a", createdAt: dayAt(2026, 6, 12) }),
entry({ id: "d1b", createdAt: dayAt(2026, 6, 12, 13) }),
entry({ id: "d2a", createdAt: dayAt(2026, 6, 13) }),
entry({ id: "d2b", createdAt: dayAt(2026, 6, 13, 13) }), // first unread
entry({ id: "d2c", createdAt: dayAt(2026, 6, 13, 14) }),
entry({ id: "d3a", createdAt: dayAt(2026, 6, 14) }),
];
const { items } = buildTimelineItems(entries, "d2b");
assert.deepEqual(kinds(items), [
"day-divider", // day 1
"message", // d1a
"message", // d1b
"day-divider", // day 2
"message", // d2a
"unread-divider", // above d2b
"message", // d2b
"message", // d2c
"day-divider", // day 3
"message", // d3a
]);
});
test("buildTimelineItems: unread divider suppressed when first unread is the first entry", () => {
const entries = [
entry({ id: "a", createdAt: dayAt(2026, 6, 14) }),
entry({ id: "b", createdAt: dayAt(2026, 6, 14, 13) }),
];
// firstUnread === index 0 — nothing above it, so no divider.
const { items } = buildTimelineItems(entries, "a");
assert.equal(items.filter((i) => i.kind === "unread-divider").length, 0);
});
test("buildTimelineItems: system messages flatten to a 'system' item", () => {
const entries = [
entry({ id: "a", createdAt: dayAt(2026, 6, 14) }),
entry({
id: "sys",
kind: KIND_SYSTEM_MESSAGE,
createdAt: dayAt(2026, 6, 14, 13),
}),
];
const { items } = buildTimelineItems(entries, null);
assert.deepEqual(kinds(items), ["day-divider", "message", "system"]);
});
test("buildTimelineItems: empty entries produce no items and an empty map", () => {
const { items, indexByMessageId } = buildTimelineItems([], null);
assert.equal(items.length, 0);
assert.equal(indexByMessageId.size, 0);
});
// --- index map correctness ---------------------------------------------------
test("buildTimelineItems: map points each message id at its flattened item index", () => {
const entries = [
entry({ id: "d1", createdAt: dayAt(2026, 6, 12) }),
entry({ id: "d2", createdAt: dayAt(2026, 6, 13) }),
];
const { items, indexByMessageId } = buildTimelineItems(entries, null);
// dividers occupy indices 0 and 2; messages land at 1 and 3.
assert.equal(indexByMessageId.get("d1"), 1);
assert.equal(indexByMessageId.get("d2"), 3);
assert.equal(items[1].entry.message.id, "d1");
assert.equal(items[3].entry.message.id, "d2");
});
test("buildTimelineItems: appending a new message keeps prior indices stable", () => {
const base = [entry({ id: "a", createdAt: dayAt(2026, 6, 14) })];
const before = buildTimelineItems(base, null).indexByMessageId;
const appended = [
...base,
entry({ id: "b", createdAt: dayAt(2026, 6, 14, 13) }),
];
const after = buildTimelineItems(appended, null).indexByMessageId;
assert.equal(after.get("a"), before.get("a"));
assert.equal(after.get("b"), 2);
});
test("buildTimelineItems: prepending an older-day message shifts later indices", () => {
const original = [entry({ id: "b", createdAt: dayAt(2026, 6, 14) })];
const beforeIdx = buildTimelineItems(original, null).indexByMessageId.get(
"b",
);
// Prepend a message on an earlier day → adds its own day-divider + message,
// pushing "b" (now on a new day boundary too) further down.
const prepended = [
entry({ id: "a", createdAt: dayAt(2026, 6, 13) }),
entry({ id: "b", createdAt: dayAt(2026, 6, 14) }),
];
const afterIdx = buildTimelineItems(prepended, null).indexByMessageId.get(
"b",
);
assert.ok(afterIdx > beforeIdx);
});
test("buildTimelineItems: deleting a message drops it from the map", () => {
const entries = [
entry({ id: "a", createdAt: dayAt(2026, 6, 14) }),
entry({ id: "b", createdAt: dayAt(2026, 6, 14, 13) }),
];
const afterDelete = buildTimelineItems(
entries.filter((e) => e.message.id !== "a"),
null,
).indexByMessageId;
assert.equal(afterDelete.has("a"), false);
assert.equal(afterDelete.get("b"), 1);
});
test("getTimelineItemKey: keys are unique across the stream", () => {
const entries = [
entry({ id: "a", createdAt: dayAt(2026, 6, 12) }),
entry({ id: "b", createdAt: dayAt(2026, 6, 13) }),
];
const { items } = buildTimelineItems(entries, "b");
const keys = items.map(getTimelineItemKey);
assert.equal(new Set(keys).size, keys.length);
});
test("resolveActiveDayTimestamp: null index returns null (nothing in view yet)", () => {
const { items } = buildTimelineItems(
[entry({ id: "a", createdAt: dayAt(2026, 6, 14) })],
null,
);
assert.equal(resolveActiveDayTimestamp(items, null), null);
});
test("resolveActiveDayTimestamp: empty stream returns null", () => {
assert.equal(resolveActiveDayTimestamp([], 0), null);
});
test("resolveActiveDayTimestamp: a top row resolves to its own day's heading", () => {
const ts = dayAt(2026, 6, 14, 9);
const { items } = buildTimelineItems(
[entry({ id: "a", createdAt: ts })],
null,
);
// items[0] is the day-divider, items[1] is the message. The message at index
// 1 resolves back to the divider above it.
assert.equal(resolveActiveDayTimestamp(items, 1), items[0].headingTimestamp);
});
test("resolveActiveDayTimestamp: a later-day row whose divider scrolled out still resolves to that later day", () => {
const day1 = dayAt(2026, 6, 13, 9);
const day2 = dayAt(2026, 6, 14, 9);
const { items } = buildTimelineItems(
[entry({ id: "a", createdAt: day1 }), entry({ id: "b", createdAt: day2 })],
null,
);
// Stream: [div(day1), a, div(day2), b]. Topmost visible = b (index 3); the
// day2 divider at index 2 is the nearest divider at or above it.
const dividerDay2 = items[2];
assert.equal(dividerDay2.kind, "day-divider");
assert.equal(
resolveActiveDayTimestamp(items, 3),
dividerDay2.headingTimestamp,
);
});
test("resolveActiveDayTimestamp: index past the end clamps to the last item", () => {
const ts = dayAt(2026, 6, 14, 9);
const { items } = buildTimelineItems(
[entry({ id: "a", createdAt: ts })],
null,
);
assert.equal(
resolveActiveDayTimestamp(items, 999),
items[0].headingTimestamp,
);
});
test("resolveActiveDayTimestamp: a divider row at the top resolves to its own heading", () => {
const ts = dayAt(2026, 6, 14, 9);
const { items } = buildTimelineItems(
[entry({ id: "a", createdAt: ts })],
null,
);
// Topmost visible is the divider itself (index 0) — it resolves to itself.
assert.equal(items[0].kind, "day-divider");
assert.equal(resolveActiveDayTimestamp(items, 0), items[0].headingTimestamp);
});

View File

@@ -0,0 +1,120 @@
/**
* Flattens the heterogeneous day-grouped timeline tree into a flat
* discriminated-union item stream that a virtualizer can window over, and
* builds the `messageId -> itemIndex` map every DOM-query scroll path now
* resolves against instead of `querySelector`.
*
* Kept pure (no React, no DOM) so it is covered by the lib-level `*.test.mjs`
* suite. The list and the index map are produced together from the SAME walk,
* so they can never drift: a stale map would scroll deep-links to the wrong
* row, the exact failure virtualization risks.
*/
import {
buildDayGroupBoundaries,
type DayGroupBoundary,
} from "@/features/messages/lib/timelineSnapshot";
import { shouldRenderUnreadDivider } from "@/features/messages/lib/threadPanel";
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";
/**
* One renderable row in the flattened timeline. Dividers carry no message and
* never appear in the index map; the three message-bearing kinds do.
*/
export type TimelineItem =
// `headingTimestamp` (not a prebaked label) so the render still resolves
// "Today"/"Yesterday" relative to the current clock, not to build time.
| { kind: "day-divider"; key: string; headingTimestamp: number }
| { kind: "unread-divider"; key: string }
| { kind: "system"; key: string; entry: MainTimelineEntry }
| { kind: "message"; key: string; entry: MainTimelineEntry };
export type TimelineItemsResult = {
items: TimelineItem[];
/** Maps a top-level message id to its index in `items`. */
indexByMessageId: Map<string, number>;
};
/** Stable per-item key, unique across the flattened stream. */
export function getTimelineItemKey(item: TimelineItem): string {
return item.key;
}
/**
* The `headingTimestamp` of the day the topmost visible row belongs to: the
* nearest day-divider at or above `topVisibleIndex`. The floating active-day
* header reads this so it always shows the current day even when that day's
* in-stream divider has scrolled out of the virtual window. Returns `null`
* before any row is in view or when no divider precedes the visible row (an
* empty stream).
*/
export function resolveActiveDayTimestamp(
items: TimelineItem[],
topVisibleIndex: number | null,
): number | null {
if (topVisibleIndex === null) {
return null;
}
for (let i = Math.min(topVisibleIndex, items.length - 1); i >= 0; i--) {
const item = items[i];
if (item.kind === "day-divider") {
return item.headingTimestamp;
}
}
return null;
}
function entryRenderKey(entry: MainTimelineEntry): string {
return entry.message.renderKey ?? entry.message.id;
}
/**
* Walks the (already top-level-filtered) entries once, emitting a day-divider
* at each calendar-day boundary and an unread-divider above the first unread
* message, then the message/system row itself. The index map records where
* each message landed in the flat stream so scroll targets resolve in O(1)
* without touching the DOM.
*/
export function buildTimelineItems(
entries: MainTimelineEntry[],
firstUnreadMessageId: string | null,
): TimelineItemsResult {
const items: TimelineItem[] = [];
const indexByMessageId = new Map<string, number>();
// Index boundaries by their start position so the walk below can look up the
// prepend-stable section key (start-of-local-day). Keying the divider by
// start-of-day, not by the first message, keeps the day section from
// remounting when older messages prepend into it.
const dayBoundariesByStartIndex = new Map(
buildDayGroupBoundaries(entries.map((entry) => entry.message)).map(
(boundary: DayGroupBoundary) => [boundary.startIndex, boundary] as const,
),
);
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
const { message } = entry;
const renderKey = entryRenderKey(entry);
const dayBoundary = dayBoundariesByStartIndex.get(i);
if (dayBoundary) {
items.push({
kind: "day-divider",
key: dayBoundary.key,
headingTimestamp: message.createdAt,
});
}
if (shouldRenderUnreadDivider(i, message.id, firstUnreadMessageId)) {
items.push({ kind: "unread-divider", key: `unread-${renderKey}` });
}
const kind = message.kind === KIND_SYSTEM_MESSAGE ? "system" : "message";
indexByMessageId.set(message.id, items.length);
items.push({ kind, key: renderKey, entry });
}
return { items, indexByMessageId };
}

View File

@@ -0,0 +1,26 @@
/**
* Floating active-day label for the virtualized timeline.
*
* The in-stream `DayDivider` rows mark each calendar boundary, but once a row
* scrolls out of the virtual window it unmounts — so a `position: sticky`
* divider can no longer hold the viewport top, and a sticky element inside the
* scroll container drifts at scrollTop 0 (it reveals its natural flow offset
* instead of the pinned offset). This label is therefore portaled by
* `VirtualizedList` into a non-scrolling overlay container OUTSIDE the scroll
* element (see `MessageTimeline`), where it pins to a fixed viewport offset and
* cannot drift as older history prepends above the anchor. It is fed the day of
* the topmost visible row so the label is always present regardless of which
* dividers are currently mounted.
*/
export function ActiveDayHeader({ label }: { label: string }) {
return (
<p
aria-hidden
className="pointer-events-none mx-auto w-fit rounded-full border border-border/70 bg-background/95 px-2.5 py-1 text-2xs font-medium tracking-[0.02em] text-muted-foreground/70 shadow-xs backdrop-blur-sm"
data-testid="message-timeline-active-day-header"
data-day-label={label}
>
{label}
</p>
);
}

View File

@@ -378,10 +378,6 @@ export function MessageThreadPanel({
}: 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 [hoveredCollapseBranchId, setHoveredCollapseBranchId] = React.useState<
string | null
@@ -602,7 +598,6 @@ export function MessageThreadPanel({
messages: threadMessages,
onTargetReached: onScrollTargetResolved,
scrollContainerRef: threadBodyRef,
sentinelRef: threadTopSentinelRef,
targetMessageId: scrollTargetId,
});
@@ -622,7 +617,6 @@ export function MessageThreadPanel({
ref={threadBodyRef}
>
<div ref={threadContentRef}>
<div ref={threadTopSentinelRef} aria-hidden className="h-px" />
<div
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-1 pt-0")}
data-testid="message-thread-head"
@@ -736,7 +730,7 @@ export function MessageThreadPanel({
return (
<div
className={cn(
"flex flex-col gap-0",
"content-visibility-auto flex flex-col gap-0",
entry.summary &&
"group/message rounded-2xl px-0 py-0.5 transition-colors hover:bg-muted/50 focus-within:bg-muted/50",
)}

View File

@@ -10,17 +10,22 @@ import {
import { getDmParticipantPreview } from "@/features/channels/lib/dmParticipantDisplay";
import type { TimelineMessage } from "@/features/messages/types";
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { ChannelType } from "@/shared/api/types";
import { buildTimelineItems } from "@/features/messages/lib/timelineItems";
import { cn } from "@/shared/lib/cn";
import { channelChrome } from "@/shared/layout/chromeLayout";
import { Spinner } from "@/shared/ui/spinner";
import { TooltipProvider } from "@/shared/ui/tooltip";
import { UnreadPill, unreadCountLabel } from "@/shared/ui/UnreadPill";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import type { ListVirtualizer } from "@/shared/ui/VirtualizedList";
import { TimelineSkeleton, useTimelineSkeletonRows } from "./TimelineSkeleton";
import { TimelineMessageList } from "./TimelineMessageList";
import { useAnchoredScroll } from "./useAnchoredScroll";
import { useConvergentScrollToMessage } from "./useConvergentScrollToMessage";
import { useLoadOlderOnScroll } from "./useLoadOlderOnScroll";
export type MessageTimelineHandle = {
scrollToBottomOnNextUpdate: () => void;
@@ -50,7 +55,6 @@ type MessageTimelineProps = {
/** True when the timeline has the composer overlay below it. */
hasComposerOverlay?: boolean;
isFetchingOlder?: boolean;
layoutShiftKey?: string | number | null;
messageFooters?: Record<string, React.ReactNode>;
/** Map from lowercase pubkey → persona display name for bot members. */
personaLookup?: Map<string, string>;
@@ -149,7 +153,6 @@ const MessageTimelineBase = React.forwardRef<
hasComposerOverlay = true,
hasOlderMessages = true,
isFetchingOlder = false,
layoutShiftKey = null,
followThreadById,
isFollowingThreadById,
isMessageUnreadById,
@@ -183,6 +186,43 @@ const MessageTimelineBase = React.forwardRef<
const scrollContainerRef = externalScrollRef ?? internalScrollRef;
const contentRef = React.useRef<HTMLDivElement>(null);
const topSentinelRef = React.useRef<HTMLDivElement>(null);
// The floating active-day header portals here — a non-scrolling sibling of
// the scroll container — so it pins to a fixed offset and never drifts.
const activeDayHeaderRef = React.useRef<HTMLDivElement>(null);
// The convergence fallback for a windowed-out deep-link target. It's defined
// below (it depends on the anchored-scroll result), so `useAnchoredScroll`
// reads it through a ref via a stable wrapper — letting the hook stay
// virtualizer-agnostic while the consumer owns the convergence machinery.
const convergeToTargetRef = React.useRef<(messageId: string) => boolean>(
() => false,
);
const convergeToTarget = React.useCallback((messageId: string) => {
return convergeToTargetRef.current(messageId);
}, []);
// The mount bottom-pin driven through the virtualizer. Defined here as a
// stable wrapper over a ref (assigned below, once the virtualizer/item count
// are known) so `useAnchoredScroll` stays virtualizer-agnostic — same
// indirection as `convergeToTarget`. Returns `true` once it issued the
// index scroll; `false` (thread panel, no virtualizer) → the hook falls back
// to its raw bottom pin.
const pinToBottomByIndexRef = React.useRef<() => boolean>(() => false);
const pinToBottomByIndex = React.useCallback(() => {
return pinToBottomByIndexRef.current();
}, []);
// The virtualizer instance is owned by the child TimelineMessageList (which
// mounts the VirtualizedList) and reported up here via a ref so the scroll
// manager can drive index-model scroll paths. The virtualizer arrives via
// VirtualizedList's onVirtualizer layout effect (child layout effects fire
// before parent layout effects), so getVirtualizer() is live by the time
// useAnchoredScroll's mount pin runs.
const virtualizerRef = React.useRef<ListVirtualizer | null>(null);
const handleVirtualizer = React.useCallback((instance: ListVirtualizer) => {
virtualizerRef.current = instance;
}, []);
const getVirtualizer = React.useCallback(() => virtualizerRef.current, []);
// Gate the heavy timeline render (each row runs a synchronous
// react-markdown parse) behind React concurrency. `useDeferredValue` lets the
@@ -207,6 +247,33 @@ const MessageTimelineBase = React.forwardRef<
EMPTY_TIMELINE_SNAPSHOT,
);
const deferredMessages = deferredSnapshot.messages;
// The flattened item stream mirrors what TimelineMessageList renders: use the
// hoisted mainEntries when the deferred snapshot is current (same identity as
// the live messages), fall back to building entries from deferredMessages when
// the deferred value is stale. This keeps the scroll manager's index map in
// sync with what's actually painted without a state-update round-trip.
const deferredEntries = React.useMemo(
() =>
(deferredMessages === messages ? mainEntries : undefined) ??
buildMainTimelineEntries(deferredMessages),
[mainEntries, deferredMessages, messages],
);
const timelineItems = React.useMemo(
() => buildTimelineItems(deferredEntries, firstUnreadMessageId),
[deferredEntries, firstUnreadMessageId],
);
const virtualizerOption = React.useMemo(
() =>
timelineItems.items.length > 0
? {
getVirtualizer,
indexByMessageId: timelineItems.indexByMessageId,
itemCount: timelineItems.items.length,
liveMessageCount: messages.length,
}
: null,
[getVirtualizer, timelineItems, messages.length],
);
const isDeferredSnapshotStale = isDeferredTimelineSnapshotStale({
deferredSnapshot,
liveSnapshot,
@@ -234,21 +301,21 @@ const MessageTimelineBase = React.forwardRef<
isAtBottom,
newMessageCount,
onScroll,
restoreScrollPosition,
scrollToBottom,
scrollToBottomOnNextUpdate,
scrollToMessage,
setLoadOlderRestoreInFlight,
getAnchorIsAtBottom,
} = useAnchoredScroll({
channelId,
contentRef,
fetchOlder,
hasOlderMessages,
isFetchingOlder,
convergeToTarget,
isLoading: showTimelineSkeleton,
layoutShiftKey,
messages: deferredMessages,
onTargetReached,
pinToBottomByIndex,
scrollContainerRef,
sentinelRef: topSentinelRef,
targetMessageId,
});
@@ -279,6 +346,66 @@ const MessageTimelineBase = React.forwardRef<
[scrollToBottomOnNextUpdate],
);
// Role 3 — jump-to-message into windowed-out history. The DOM-based
// `scrollToMessage` no-ops when the target row isn't mounted (virtualized
// out), so when it fails and the timeline is virtualized we drive the
// convergence adapter: it scrolls the virtualizer to the target index,
// re-aiming each frame as rows mount and measure, then on settle the row is
// in the DOM and `scrollToMessage` centers + highlights it. When there's no
// virtualizer (e.g. the thread panel), there's nothing to converge — the DOM
// path is the whole story and a missing row simply isn't reachable.
const { scrollToMessage: convergeToMessage, cancel: cancelConvergence } =
useConvergentScrollToMessage(getVirtualizer, {
indexByMessageId: timelineItems.indexByMessageId,
align: "center",
onConverged: (messageId) => {
scrollToMessage(messageId, { highlight: true });
onTargetReached?.(messageId);
},
onAbandoned: (messageId) => onTargetReached?.(messageId),
});
const jumpToMessage = React.useCallback(
(messageId: string, options?: { behavior?: ScrollBehavior }) => {
if (scrollToMessage(messageId, { highlight: true, ...options })) {
return;
}
if (virtualizerOption) {
convergeToMessage(messageId);
}
},
[convergeToMessage, scrollToMessage, virtualizerOption],
);
// Feed the windowed-out deep-link fallback back into `useAnchoredScroll`,
// which calls it when a target row isn't in the DOM. Gated on the virtualizer
// so the thread panel (no virtualizer) never converges. Assigned in an effect
// because `useAnchoredScroll` reads it asynchronously from a post-mount effect.
React.useEffect(() => {
convergeToTargetRef.current = virtualizerOption
? convergeToMessage
: () => false;
}, [convergeToMessage, virtualizerOption]);
// Drive the mount bottom-pin through the virtualizer when one is present.
// Assigned during render (not an effect) because `useAnchoredScroll`'s
// mount pin runs in a layout effect on the same commit — a passive effect
// would assign too late. `virtualizerOption` is derived from `timelineItems`
// via useMemo (no state update, no extra render), so it is non-null from the
// first deferred commit that carries real messages. `getVirtualizer()` is
// live by pin time because VirtualizedList publishes the instance in a
// layout effect, and child layout effects fire before parent layout effects.
pinToBottomByIndexRef.current = virtualizerOption
? () => {
const virtualizer = getVirtualizer();
const lastIndex = virtualizerOption.itemCount - 1;
if (!virtualizer || lastIndex < 0) return false;
virtualizer.scrollToIndex(lastIndex, { align: "end" });
return true;
}
: () => false;
// Abandon any in-flight convergence on channel switch so a stale loop can't
// hijack the new channel's scroll position.
// biome-ignore lint/correctness/useExhaustiveDependencies: cancel on channel switch only
React.useEffect(() => cancelConvergence, [channelId, cancelConvergence]);
// The unread pill is a transient, per-open affordance: dismiss it once the
// user acts on it (jumps to the oldest unread) or catches up by reaching the
// bottom of the timeline. Reset when the channel changes so a freshly opened
@@ -308,13 +435,14 @@ const MessageTimelineBase = React.forwardRef<
const handleJumpToOldestUnread = React.useCallback(() => {
setIsUnreadPillDismissed(true);
if (firstUnreadMessageId) {
scrollToMessage(firstUnreadMessageId);
jumpToMessage(firstUnreadMessageId);
}
}, [firstUnreadMessageId, scrollToMessage]);
}, [firstUnreadMessageId, jumpToMessage]);
// 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.
// Scroll to the active search match when it changes. `jumpToMessage` updates
// the scroll anchor (so the post-commit restore won't yank the view back off
// the match) and, when virtualized, converges on the target through the index
// model — the row may be windowed out of the DOM.
const prevSearchActiveRef = React.useRef<string | null>(null);
React.useEffect(() => {
if (showTimelineSkeleton) return;
@@ -326,8 +454,20 @@ const MessageTimelineBase = React.forwardRef<
return;
}
prevSearchActiveRef.current = searchActiveMessageId;
scrollToMessage(searchActiveMessageId, { behavior: "smooth" });
}, [scrollToMessage, searchActiveMessageId, showTimelineSkeleton]);
jumpToMessage(searchActiveMessageId, { behavior: "smooth" });
}, [jumpToMessage, searchActiveMessageId, showTimelineSkeleton]);
useLoadOlderOnScroll({
fetchOlder,
hasOlderMessages,
isLoading: showTimelineSkeleton,
restoreScrollPosition,
setLoadOlderRestoreInFlight,
getAnchorIsAtBottom,
scrollContainerRef,
sentinelRef: topSentinelRef,
virtualizer: virtualizerOption,
});
const timelineSkeletonRows = useTimelineSkeletonRows({
channelId,
@@ -338,6 +478,18 @@ const MessageTimelineBase = React.forwardRef<
return (
<TooltipProvider delayDuration={200}>
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{/* Non-scrolling overlay anchored to the outer (non-scrolling) box: the
floating active-day header portals in here, so it pins to a fixed
offset and cannot drift as older history prepends. Sits below the
unread pill / fetch spinner (z-20) in the stack. */}
<div
aria-hidden
className={cn(
"pointer-events-none absolute inset-x-0 z-[6] flex translate-y-3 justify-center px-4",
channelChrome.top,
)}
ref={activeDayHeaderRef}
/>
{showUnreadPill ? (
<div
className={cn(
@@ -390,6 +542,13 @@ const MessageTimelineBase = React.forwardRef<
>
<div ref={topSentinelRef} aria-hidden className="h-px" />
{/* Fixed-height slot: an always-mounted height keeps the virtual
spacer's offset stable across the load-older fetch toggle, so
`scrollMargin` doesn't shift mid-fetch and yank the restore. The
visible fetch spinner lives in the absolute overlay above, which
does not occupy inline flow. */}
<div aria-hidden className="h-8" />
<div
className={cn(
"flex min-h-[18rem] min-w-0 flex-col gap-2",
@@ -534,6 +693,7 @@ const MessageTimelineBase = React.forwardRef<
data-render-pending={isRenderPending ? "true" : undefined}
>
<TimelineMessageList
key={scrollContainerDomKey}
agentPubkeys={agentPubkeys}
channelId={channelId}
channelName={channelName}
@@ -564,6 +724,9 @@ const MessageTimelineBase = React.forwardRef<
searchQuery={searchQuery}
threadUnreadCounts={threadUnreadCounts}
unfollowThreadById={unfollowThreadById}
scrollContainerRef={scrollContainerRef}
headerOverlayRef={activeDayHeaderRef}
onVirtualizer={handleVirtualizer}
/>
</div>
) : null}

View File

@@ -1,25 +1,29 @@
import * as React from "react";
import { formatDayHeading } from "@/features/messages/lib/dateFormatters";
import {
formatDayHeading,
isSameDay,
startOfLocalDaySeconds,
} from "@/features/messages/lib/dateFormatters";
buildTimelineItems,
getTimelineItemKey,
resolveActiveDayTimestamp,
type TimelineItem,
} from "@/features/messages/lib/timelineItems";
import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel";
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
import {
buildMainTimelineEntries,
shouldRenderUnreadDivider,
} from "@/features/messages/lib/threadPanel";
import {
buildVideoReviewCommentsForRoot,
buildVideoReviewCommentsByRootId,
buildVideoReviewContextForMessage,
hasVideoAttachment,
} from "@/features/messages/lib/videoReviewContext";
import type { TimelineMessage } from "@/features/messages/types";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { ChannelType } from "@/shared/api/types";
import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";
import { cn } from "@/shared/lib/cn";
import {
type ListVirtualizer,
VirtualizedList,
} from "@/shared/ui/VirtualizedList";
import { DayDivider } from "./DayDivider";
import { ActiveDayHeader } from "./ActiveDayHeader";
import { MessageRow } from "./MessageRow";
import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow";
import { SystemMessageRow } from "./SystemMessageRow";
@@ -38,7 +42,9 @@ type TimelineMessageListProps = {
isFollowingThreadById?: (rootId: string) => boolean;
isMessageUnreadById?: (messageId: string) => boolean;
messageFooters?: Record<string, React.ReactNode>;
mainEntries?: ReturnType<typeof buildMainTimelineEntries>;
/** Hoisted main-timeline entries (computed once in ChannelPane). Falls back
* to deriving them here when omitted (e.g. the deferred-render pass). */
mainEntries?: MainTimelineEntry[];
messages: TimelineMessage[];
onDelete?: (message: TimelineMessage) => void;
onEdit?: (message: TimelineMessage) => void;
@@ -70,118 +76,15 @@ type TimelineMessageListProps = {
searchQuery?: string;
/** Per-thread unread counts keyed by thread root id. */
threadUnreadCounts?: ReadonlyMap<string, number>;
/** Caller-owned scroll container the virtualizer measures and scrolls. */
scrollContainerRef: React.RefObject<HTMLElement | null>;
/** Non-scrolling overlay container the floating active-day header portals
* into — sits OUTSIDE the scroll container so the header cannot drift. */
headerOverlayRef: React.RefObject<HTMLElement | null>;
/** Receives the virtualizer instance for index-model scroll paths. */
onVirtualizer?: (virtualizer: ListVirtualizer) => void;
};
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;
type TimelineNonDayRow = TimelineUnreadRow | TimelineMessageRowModel;
type TimelineDayGroup = {
key: string;
label: string;
rows: TimelineNonDayRow[];
};
function buildTimelineRenderRows({
entries,
firstUnreadMessageId,
messages,
}: {
entries?: ReturnType<typeof buildMainTimelineEntries>;
firstUnreadMessageId: string | null;
messages: TimelineMessage[];
}): TimelineRenderRow[] {
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;
}
function buildTimelineDayGroups(rows: TimelineRenderRow[]): TimelineDayGroup[] {
const groups: TimelineDayGroup[] = [];
let currentGroup: TimelineDayGroup | null = null;
for (const row of rows) {
if (row.type === "day") {
currentGroup = {
key: row.key,
label: row.label,
rows: [],
};
groups.push(currentGroup);
continue;
}
if (!currentGroup) {
currentGroup = {
key: "day-undated",
label: "",
rows: [],
};
groups.push(currentGroup);
}
currentGroup.rows.push(row);
}
return groups;
}
export const TimelineMessageList = React.memo(function TimelineMessageList({
agentPubkeys,
channelId,
@@ -210,169 +113,271 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
searchQuery,
threadUnreadCounts,
unfollowThreadById,
scrollContainerRef,
headerOverlayRef,
onVirtualizer,
}: TimelineMessageListProps) {
const rows = React.useMemo(
() =>
buildTimelineRenderRows({
entries: mainEntries,
firstUnreadMessageId,
messages,
}),
[firstUnreadMessageId, mainEntries, messages],
const entries = React.useMemo(
() => mainEntries ?? buildMainTimelineEntries(messages),
[mainEntries, messages],
);
const dayGroups = React.useMemo(() => buildTimelineDayGroups(rows), [rows]);
const reviewCommentsByRootId = React.useMemo(
() =>
messages.some(hasVideoAttachment)
? buildVideoReviewCommentsByRootId(messages)
: new Map<string, TimelineMessage[]>(),
[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 contexts;
}, [
channelId,
channelName,
channelType,
isSendingVideoReviewComment,
messages,
onSendVideoReviewComment,
onToggleReaction,
profiles,
reviewCommentsByRootId,
]);
return (
<div className="flex flex-col gap-0">
{dayGroups.map((group) => (
<section
className="relative flex flex-col gap-2 before:absolute before:inset-x-0 before:top-4 before:h-px before:bg-border/35 before:content-['']"
data-day-label={group.label}
data-testid="message-timeline-day-group"
key={group.key}
>
{group.label ? <DayDivider label={group.label} /> : null}
{group.rows.map((row) => (
<TimelineRenderRowView
agentPubkeys={agentPubkeys}
allMessages={
row.type === "message" && hasVideoAttachment(row.message)
? messages
: undefined
}
channelId={channelId}
channelName={channelName}
channelType={channelType}
// The flattened item stream and its messageId -> itemIndex map are produced
// together from ONE memo, keyed on the entries and the unread boundary (the
// unread divider is its own item, so it shifts indices). A separate memo with
// diverging deps would let the map go stale and scroll deep-links to the wrong
// row — the exact failure virtualization risks.
const itemsResult = React.useMemo(
() => buildTimelineItems(entries, firstUnreadMessageId),
[entries, firstUnreadMessageId],
);
const renderItem = React.useCallback(
(item: TimelineItem) => {
switch (item.kind) {
case "day-divider":
// Heading is resolved at render time (not baked into the item) so
// "Today"/"Yesterday" track the wall clock, not build time.
return <DayDivider label={formatDayHeading(item.headingTimestamp)} />;
case "unread-divider":
return <UnreadDivider />;
case "system":
return (
<SystemRow
currentPubkey={currentPubkey}
followThreadById={followThreadById}
highlightedMessageId={highlightedMessageId}
isFollowingThreadById={isFollowingThreadById}
isMessageUnreadById={isMessageUnreadById}
isSendingVideoReviewComment={isSendingVideoReviewComment}
key={row.key}
messageFooters={messageFooters}
onDelete={onDelete}
onEdit={onEdit}
onMarkUnread={onMarkUnread}
onMarkRead={onMarkRead}
onReply={onReply}
onSendVideoReviewComment={onSendVideoReviewComment}
entry={item.entry}
footer={messageFooters?.[item.entry.message.id] ?? null}
onToggleReaction={onToggleReaction}
profiles={profiles}
/>
);
case "message":
return (
<MessageRowItem
agentPubkeys={agentPubkeys}
channelId={channelId}
currentPubkey={currentPubkey}
entry={item.entry}
followThreadById={followThreadById}
footer={messageFooters?.[item.entry.message.id] ?? null}
highlightedMessageId={highlightedMessageId}
isFollowingThreadById={isFollowingThreadById}
isUnread={isMessageUnreadById?.(item.entry.message.id)}
onDelete={onDelete}
onEdit={onEdit}
onMarkRead={onMarkRead}
onMarkUnread={onMarkUnread}
onReply={onReply}
onToggleReaction={onToggleReaction}
profiles={profiles}
row={row}
searchActiveMessageId={searchActiveMessageId}
searchMatchingMessageIds={searchMatchingMessageIds}
searchQuery={searchQuery}
threadUnreadCounts={threadUnreadCounts}
unfollowThreadById={unfollowThreadById}
videoReviewContext={videoReviewContextById.get(
item.entry.message.id,
)}
/>
))}
</section>
))}
</div>
);
}
},
[
agentPubkeys,
channelId,
currentPubkey,
followThreadById,
highlightedMessageId,
isFollowingThreadById,
isMessageUnreadById,
messageFooters,
onDelete,
onEdit,
onMarkRead,
onMarkUnread,
onReply,
onToggleReaction,
profiles,
searchActiveMessageId,
searchMatchingMessageIds,
searchQuery,
threadUnreadCounts,
unfollowThreadById,
videoReviewContextById,
],
);
// The floating active-day header is portaled by VirtualizedList into the
// non-scrolling overlay container (outside the scroll element) and is
// re-invoked with the topmost visible row index on every scroll. We resolve
// that index back to the day it belongs to so the label tracks the viewport
// even after that day's in-stream divider has been windowed out.
const renderStickyHeader = React.useCallback(
(topVisibleIndex: number | null) => {
const headingTimestamp = resolveActiveDayTimestamp(
itemsResult.items,
topVisibleIndex,
);
if (headingTimestamp === null) {
return null;
}
return <ActiveDayHeader label={formatDayHeading(headingTimestamp)} />;
},
[itemsResult.items],
);
return (
<VirtualizedList
getItemKey={getTimelineItemKey}
headerOverlayRef={headerOverlayRef}
innerClassName="flex flex-col"
items={itemsResult.items}
onVirtualizer={onVirtualizer}
renderItem={renderItem}
scrollRef={scrollContainerRef}
stickyHeader={renderStickyHeader}
/>
);
});
type TimelineRenderRowViewProps = Omit<
function SystemRow({
currentPubkey,
entry,
footer,
onToggleReaction,
profiles,
}: {
currentPubkey?: string;
entry: MainTimelineEntry;
footer: React.ReactNode;
onToggleReaction?: TimelineMessageListProps["onToggleReaction"];
profiles?: UserProfileLookup;
}) {
return (
<div className="flex flex-col gap-1 pb-2.5">
<SystemMessageRow
message={entry.message}
currentPubkey={currentPubkey}
onToggleReaction={onToggleReaction}
profiles={profiles}
/>
{footer}
</div>
);
}
type MessageRowItemProps = Pick<
TimelineMessageListProps,
"firstUnreadMessageId" | "messages" | "personaLookup"
| "agentPubkeys"
| "channelId"
| "currentPubkey"
| "followThreadById"
| "highlightedMessageId"
| "isFollowingThreadById"
| "onDelete"
| "onEdit"
| "onMarkUnread"
| "onMarkRead"
| "onReply"
| "onToggleReaction"
| "profiles"
| "searchActiveMessageId"
| "searchMatchingMessageIds"
| "searchQuery"
| "threadUnreadCounts"
| "unfollowThreadById"
> & {
allMessages?: TimelineMessage[];
row: TimelineRenderRow;
entry: MainTimelineEntry;
footer: React.ReactNode;
isUnread?: boolean;
videoReviewContext: ReturnType<typeof buildVideoReviewContextForMessage>;
};
const TimelineRenderRowView = React.memo(function TimelineRenderRowView({
function MessageRowItem({
agentPubkeys,
allMessages,
channelId,
channelName,
channelType,
currentPubkey,
entry,
followThreadById,
highlightedMessageId = null,
footer,
highlightedMessageId,
isFollowingThreadById,
isMessageUnreadById,
isSendingVideoReviewComment = false,
messageFooters,
isUnread,
onDelete,
onEdit,
onMarkUnread,
onMarkRead,
onReply,
onSendVideoReviewComment,
onToggleReaction,
profiles,
searchActiveMessageId = null,
searchActiveMessageId,
searchMatchingMessageIds,
searchQuery,
row,
threadUnreadCounts,
unfollowThreadById,
}: TimelineRenderRowViewProps) {
const messageForContext = row.type === "message" ? row.message : null;
const videoReviewContext = React.useMemo(() => {
if (!allMessages || !messageForContext) {
return undefined;
}
return buildVideoReviewContextForMessage({
channelId,
channelName,
channelType,
comments: buildVideoReviewCommentsForRoot(
allMessages,
messageForContext.id,
),
isSendingVideoReviewComment,
message: messageForContext,
onSendVideoReviewComment,
onToggleReaction,
profiles,
});
}, [
allMessages,
channelId,
channelName,
channelType,
isSendingVideoReviewComment,
messageForContext,
onSendVideoReviewComment,
onToggleReaction,
profiles,
]);
if (row.type === "day") {
return <DayDivider label={row.label} />;
}
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>
);
}
videoReviewContext,
}: MessageRowItemProps) {
const { message, summary } = entry;
const canDelete =
onDelete && currentPubkey && message.pubkey === currentPubkey
? onDelete
: undefined;
const canEdit =
onEdit && currentPubkey && message.pubkey === currentPubkey
? onEdit
: undefined;
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",
"group/message relative mx-1 flex flex-col gap-0 rounded-2xl px-0 py-1 pb-2.5 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",
)}
@@ -387,23 +392,15 @@ const TimelineRenderRowView = React.memo(function TimelineRenderRowView({
? isFollowingThreadById(message.id)
: undefined
}
isUnread={isMessageUnreadById?.(message.id)}
isUnread={isUnread}
message={message}
onDelete={
onDelete && currentPubkey && message.pubkey === currentPubkey
? onDelete
: undefined
}
onEdit={
onEdit && currentPubkey && message.pubkey === currentPubkey
? onEdit
: undefined
}
onDelete={canDelete}
onEdit={canEdit}
onFollowThread={
followThreadById ? () => followThreadById(message.id) : undefined
}
onMarkUnread={onMarkUnread}
onMarkRead={onMarkRead}
onMarkUnread={onMarkUnread}
onToggleReaction={onToggleReaction}
onReply={onReply}
onUnfollowThread={
@@ -430,28 +427,19 @@ const TimelineRenderRowView = React.memo(function TimelineRenderRowView({
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">
<div className="flex flex-col gap-1 pb-2.5">
<MessageRow
agentPubkeys={agentPubkeys}
channelId={channelId}
highlighted={message.id === highlightedMessageId || isSearchActive}
isUnread={isMessageUnreadById?.(message.id)}
isUnread={isUnread}
message={message}
onDelete={
onDelete && currentPubkey && message.pubkey === currentPubkey
? onDelete
: undefined
}
onEdit={
onEdit && currentPubkey && message.pubkey === currentPubkey
? onEdit
: undefined
}
onMarkUnread={onMarkUnread}
onDelete={canDelete}
onEdit={canEdit}
onMarkRead={onMarkRead}
onMarkUnread={onMarkUnread}
onToggleReaction={onToggleReaction}
onReply={onReply}
profiles={profiles}
@@ -462,4 +450,4 @@ const TimelineRenderRowView = React.memo(function TimelineRenderRowView({
{footer}
</div>
);
});
}

View File

@@ -20,9 +20,6 @@ type UseAnchoredScrollOptions = {
/** 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. */
@@ -30,19 +27,28 @@ type UseAnchoredScrollOptions = {
/** 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. Threaded in as a
* restoration re-run trigger so the anchor reasserts itself around the
* prepend on the fetch-state toggle, not only on the `messages` change. */
isFetchingOlder?: boolean;
/** Re-runs restoration when surrounding chrome changes size without changing messages. */
layoutShiftKey?: string | number | null;
/** When set, scroll to and highlight this message on mount and on change. */
targetMessageId?: string | null;
onTargetReached?: (messageId: string) => void;
/** Optional convergence fallback for a `targetMessageId` whose row is not in
* the DOM (windowed out of a virtualized list). When the DOM lookup fails,
* the hook delegates to this instead of waiting for a later commit that may
* never render the row. The consumer drives the virtualizer to the target,
* warming up if it's still being fetched, and fires `onTargetReached` itself
* on settle. Returns `true` once it owns the target (the hook marks it
* handled, no further dispatch). Absent (thread panel) → DOM-only retry. */
convergeToTarget?: (messageId: string) => boolean;
/** Optional: pin the initial mount view to the last row through the
* virtualizer's own `scrollToIndex(lastIndex, {align:"end"})`, rather than a
* raw `scrollTo(scrollHeight)`. At mount the virtualizer's rows are still
* estimate-sized, so `scrollHeight` is short of the true total and a raw pin
* lands above the real bottom (and re-pins chase a moving floor). Driving the
* last index through the library lets it own landing that row at the bottom
* across its own measurement. Returns `true` once it issued (caller keeps the
* at-bottom anchor); `false` when no virtualizer is available (thread panel),
* so the hook falls back to the raw bottom pin. */
pinToBottomByIndex?: () => boolean;
};
type UseAnchoredScrollResult = {
@@ -66,6 +72,20 @@ type UseAnchoredScrollResult = {
messageId: string,
options?: { highlight?: boolean; behavior?: ScrollBehavior },
) => boolean;
/** Single-writer scroll restore for the load-older index path. Sets
* `scrollTop` directly (no scroll event fires for a programmatic write),
* then re-seats the anchor + at-bottom bookkeeping so the next passive
* restore and `isAtBottom` read agree with where we put the scroll. */
restoreScrollPosition: (scrollTop: number) => void;
/** Brackets the load-older index restore's scroll ownership. While `true`,
* the ResizeObserver cedes — the index path is the sole `scrollTop` writer
* across the prepend, mirroring the `convergingTargetIdRef` cede. */
setLoadOlderRestoreInFlight: (inFlight: boolean) => void;
/** Live read of whether the anchor is stuck-to-bottom. The load-older index
* restore loop polls this each frame so a mid-prepend jump-to-bottom
* (abandon) re-aims it at the bottom instead of the captured mid-history
* anchor. */
getAnchorIsAtBottom: () => boolean;
};
function isAtBottomNow(container: HTMLDivElement) {
@@ -190,7 +210,19 @@ function restoreAnchorToMessage(
}
if (!anchorEl) {
// Anchor message and all subsequent rendered messages are gone.
// The anchor row is not in the DOM. Two distinct causes — only one means
// "go to bottom":
// 1. Deleted: the anchor and all newer rows are gone from `messages`
// (e.g. message deletion). Nothing newer to anchor to → pin to bottom.
// 2. Windowed out: the virtualizer dropped the anchor row from its
// rendered window because the user scrolled it off-screen (it is still
// present in `messages`). Pinning to bottom here would yank the view
// back down mid-scroll — the wheel-up-snaps-back regression. Leave the
// scroll where the user put it and keep the anchor; the virtualizer
// will render the row again when it re-enters the window.
if (messages.some((m) => m.id === anchor.messageId)) {
return anchor;
}
container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
return { kind: "at-bottom" };
}
@@ -207,16 +239,14 @@ function restoreAnchorToMessage(
export function useAnchoredScroll({
scrollContainerRef,
contentRef,
sentinelRef,
channelId,
isLoading,
messages,
fetchOlder,
hasOlderMessages = false,
isFetchingOlder = false,
layoutShiftKey = null,
targetMessageId = null,
onTargetReached,
convergeToTarget,
pinToBottomByIndex,
}: 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
@@ -234,16 +264,54 @@ export function useAnchoredScroll({
>(null);
const hasInitializedRef = React.useRef(false);
// Mirror the convergence fallback into a ref so the target effects read the
// live callback without re-subscribing on every consumer render.
const convergeToTargetRef = React.useRef(convergeToTarget);
convergeToTargetRef.current = convergeToTarget;
// Mirror the index-driven mount pin into a ref for the same reason — the
// layout effect reads the live callback without re-subscribing on every
// consumer render. Absent (thread panel, no virtualizer) → falls back to the
// raw bottom pin.
const pinToBottomByIndexRef = React.useRef(pinToBottomByIndex);
pinToBottomByIndexRef.current = pinToBottomByIndex;
const prevLastMessageIdRef = React.useRef<string | undefined>(undefined);
// Tracks the FRONT (oldest) rendered id so the restore effect can detect a
// load-older prepend (front changed, tail unchanged) and cede it to the
// index path — see IMPORTANT #1 in the restore effect below.
const prevFirstMessageIdRef = React.useRef<string | undefined>(undefined);
const prevMessageCountRef = React.useRef(0);
const fetchingOlderRef = React.useRef(false);
const handledTargetIdRef = React.useRef<string | null>(null);
// Set while a convergence loop owns the scroll position (jump-to-message into
// windowed-out history). The library's reconcile loop is the sole writer
// during convergence, so the anchored restore below must cede — otherwise its
// at-bottom `scrollTo` would yank the view back as the target row splices in,
// the same two-writer contention the prepend bail prevents. Cleared when the
// target settles (consumer clears the route param → `targetMessageId` null).
const convergingTargetIdRef = React.useRef<string | null>(null);
// Set while `useLoadOlderOnScroll`'s index-restore loop owns scroll across a
// load-older prepend. That loop drives the virtualizer to re-aim the anchored
// row as the prepended rows measure; the ResizeObserver must cede for the
// same reason it cedes during convergence — otherwise the prepended rows
// growing `scrollHeight` fire the observer with the (now windowed-out) anchor,
// its all-gone fallback pins to the floor, and stomps the index restore's
// correct offset. The layout effect already cedes the prepend (isPrepend
// bail); this is the matching cede for the non-React-driven observer writer.
const loadOlderRestoreInFlightRef = React.useRef(false);
const highlightTimeoutRef = React.useRef<number | null>(null);
// Tracks a pending rAF queued by pinToBottomOnMount so it can be cancelled
// on channel switch (the channelId reset effect clears it).
const mountPinRafIdRef = 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);
// True from the initial bottom pin until the virtualizer's row measurement
// settles and the view reaches a true at-bottom. During this window `onScroll`
// ignores the transient gap the settle opens (see the guard there). A `ref`,
// not state — the guard runs on a native scroll event, outside React's render
// cycle.
const settlingRef = 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
@@ -256,14 +324,21 @@ export function useAnchoredScroll({
setHighlightedMessageId(null);
hasInitializedRef.current = false;
prevLastMessageIdRef.current = undefined;
prevFirstMessageIdRef.current = undefined;
prevMessageCountRef.current = 0;
fetchingOlderRef.current = false;
handledTargetIdRef.current = null;
convergingTargetIdRef.current = null;
loadOlderRestoreInFlightRef.current = false;
forceBottomOnNextAppendRef.current = false;
settlingRef.current = false;
if (highlightTimeoutRef.current !== null) {
window.clearTimeout(highlightTimeoutRef.current);
highlightTimeoutRef.current = null;
}
if (mountPinRafIdRef.current !== null) {
cancelAnimationFrame(mountPinRafIdRef.current);
mountPinRafIdRef.current = null;
}
}, [channelId]);
const scrollToBottomImperative = React.useCallback(
@@ -271,6 +346,17 @@ export function useAnchoredScroll({
const container = scrollContainerRef.current;
if (!container) return;
anchorRef.current = { kind: "at-bottom" };
// A smooth (animated) jump-to-latest is not atomic: the browser scrolls
// over several frames, and each intermediate `scroll` event would drive
// `onScroll` → `computeAnchor`, which latches a mid-history message anchor
// because the view is transiently NOT at the bottom. The ResizeObserver
// then "restores" that stale anchor and strands the view mid-list. Arm
// the same settle window the mount pin uses so `onScroll` holds the
// at-bottom anchor until the animation lands a true at-bottom; the
// observer's at-bottom re-pin then keeps the view glued as late row
// measurement grows `scrollHeight`. (An instant "auto" scroll completes
// before any scroll event, so it needs no guard.)
if (behavior === "smooth") settlingRef.current = true;
container.scrollTo({ top: container.scrollHeight, behavior });
setIsAtBottom(true);
setNewMessageCount(0);
@@ -346,12 +432,77 @@ export function useAnchoredScroll({
[scrollContainerRef],
);
// Re-seat the anchor + at-bottom bookkeeping after a programmatic scrollTop
// write. A programmatic write fires no scroll event, so `onScroll` won't run
// to refresh `anchorRef`/`isAtBottom` — we run the same derivation here so
// the next passive restore and at-bottom read agree with the new position.
// We deliberately do NOT touch `newMessageCount`: a load-older restore keeps
// the reader mid-history, so the unread-count affordance must be untouched.
const syncAnchorAfterProgrammaticScroll = React.useCallback(
(container: HTMLDivElement) => {
anchorRef.current = computeAnchor(container);
const atBottom = anchorRef.current.kind === "at-bottom";
setIsAtBottom((prev) => (prev === atBottom ? prev : atBottom));
},
[],
);
// Single-writer restore for the load-older index path (IMPORTANT #2). The
// index path resolves the exact target `scrollTop` off the virtualizer's
// settled measurement cache (`getOffsetForIndex`), so a bare assignment is
// correct on the first write — no rAF re-assert loop, no manager scroll-state
// machine. Re-seating the anchor afterwards keeps this the sole owner.
const restoreScrollPosition = React.useCallback(
(scrollTop: number) => {
const container = scrollContainerRef.current;
if (!container) return;
container.scrollTop = scrollTop;
syncAnchorAfterProgrammaticScroll(container);
},
[scrollContainerRef, syncAnchorAfterProgrammaticScroll],
);
// Let the load-older index path mark its scroll-ownership window so the
// ResizeObserver cedes to it (see `loadOlderRestoreInFlightRef`).
const setLoadOlderRestoreInFlight = React.useCallback((inFlight: boolean) => {
loadOlderRestoreInFlightRef.current = inFlight;
}, []);
// Live read of whether the anchor is stuck-to-bottom, for the load-older
// restore loop. If the user jumps to bottom (abandon) WHILE that loop owns
// scroll, the loop is re-aiming `scrollToOffset` at the captured mid-history
// anchor and would land there, not at the bottom the user asked for — and the
// ResizeObserver re-pin is ceded to the loop for the whole window, so nothing
// chases the last rows to the true floor. The loop reads this each frame to
// detect the abandon and re-aim to the bottom instead.
const getAnchorIsAtBottom = React.useCallback(
() => anchorRef.current.kind === "at-bottom",
[],
);
// 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;
// A virtualizer settle grows `scrollHeight` (rows below the fold measure a
// frame or two after the initial bottom pin) and emits scroll events while
// `scrollTop` holds at the old floor — opening a transient gap above the
// true bottom. `computeAnchor` would read that gap as a deliberate
// scroll-up and latch a message anchor, freezing the view short of the
// bottom (the non-virtualized list had full height at pin time, so it never
// produced this gap). While settling, keep the anchor at-bottom so the
// resize observer's re-pin can finish chasing the floor; clear the window
// once a scroll event reads a genuine at-bottom. Scoped to the initial
// settle so post-settle user scroll-up re-evaluates the anchor normally.
if (settlingRef.current) {
if (isAtBottomNow(container)) {
settlingRef.current = false;
} else {
return;
}
}
anchorRef.current = computeAnchor(container);
const atBottom = anchorRef.current.kind === "at-bottom";
setIsAtBottom((prev) => (prev === atBottom ? prev : atBottom));
@@ -363,10 +514,12 @@ export function useAnchoredScroll({
// ---------------------------------------------------------------------------
// 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.
// before the render. This keeps scroll stable across appends, image loads,
// and embed expansions. Load-older prepends are NOT handled here — they are
// ceded to `useLoadOlderOnScroll`'s index anchor (see the prepend bail
// below) so a single writer owns `scrollTop` on the prepend commit.
// ---------------------------------------------------------------------------
// biome-ignore lint/correctness/useExhaustiveDependencies: `isFetchingOlder` and `layoutShiftKey` are intentional re-run triggers, not reads. They re-run restoration when fetch state or surrounding layout changes so the anchor reasserts itself; the correction is a no-op when nothing above the anchor moved.
React.useLayoutEffect(() => {
const container = scrollContainerRef.current;
if (!container) return;
@@ -381,6 +534,31 @@ export function useAnchoredScroll({
// to the requested target message, or to the bottom by default.
if (!hasInitializedRef.current) {
if (isLoading) return;
// Mount bottom pin. Prefer the virtualizer's own
// `scrollToIndex(lastIndex, {align:"end"})` (via `pinToBottomByIndex`):
// at mount the rows are estimate-sized, so a raw `scrollTo(scrollHeight)`
// lands above the true bottom and the last rows may not render into the
// window. Driving the last index through TanStack lands that row at the
// bottom across its own measurement. Falls back to the raw pin when no
// virtualizer is present (thread panel) or it declines.
// Track a pending rAF so the cleanup can cancel it if the effect
// re-runs (channel switch) before the frame fires.
const pinToBottomOnMount = () => {
// Set the anchor synchronously so settlingRef is correct below.
anchorRef.current = { kind: "at-bottom" };
// Defer the actual scroll call out of the layout effect. TanStack's
// virtualizer calls flushSync(rerender) on scroll events when
// useFlushSync=true (the default), which would trigger a synchronous
// React re-render inside this layout effect and mutate the DOM before
// Playwright (and the browser) finish measuring layout. Deferring to
// rAF lets the current paint commit complete first.
mountPinRafIdRef.current = requestAnimationFrame(() => {
mountPinRafIdRef.current = null;
if (!pinToBottomByIndexRef.current?.()) {
scrollToBottomImperative("auto");
}
});
};
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
@@ -396,20 +574,27 @@ export function useAnchoredScroll({
// the param sticks and re-clicking the same deep link is a no-op.
onTargetReached?.(targetMessageId);
} else {
scrollToBottomImperative("auto");
pinToBottomOnMount();
}
} else {
scrollToBottomImperative("auto");
pinToBottomOnMount();
}
hasInitializedRef.current = true;
// Arm the settle window only when we pinned to bottom — that's the path
// that suffers the late spacer growth. A successful deep-link target
// leaves a message anchor and must not be treated as a settling bottom.
settlingRef.current = anchorRef.current.kind === "at-bottom";
prevLastMessageIdRef.current = messages[messages.length - 1]?.id;
prevFirstMessageIdRef.current = messages[0]?.id;
prevMessageCountRef.current = messages.length;
return;
}
const anchor = anchorRef.current;
const lastMessage = messages[messages.length - 1];
const firstMessage = messages[0];
const prevLastId = prevLastMessageIdRef.current;
const prevFirstId = prevFirstMessageIdRef.current;
const prevCount = prevMessageCountRef.current;
const newLatestArrived =
lastMessage !== undefined && lastMessage.id !== prevLastId;
@@ -418,6 +603,36 @@ export function useAnchoredScroll({
// same-second row, so the list grows without the *last* id changing —
// `newLatestArrived` misses that case and the unread counter never bumps.
const messagesArrived = messages.length - prevCount;
// A convergence loop owns the scroll position while jumping to a windowed-out
// target (its library reconcile is the sole writer). Cede every restore
// branch to it — an at-bottom `scrollTo` here would chase the view back to
// the bottom as the target's neighbours splice into the window mid-converge.
// Refresh the tracked refs so the first post-settle commit isn't misread as
// a prepend/append. Cleared when the target settles (targetMessageId null).
if (convergingTargetIdRef.current !== null) {
prevLastMessageIdRef.current = lastMessage?.id;
prevFirstMessageIdRef.current = firstMessage?.id;
prevMessageCountRef.current = messages.length;
return;
}
// A load-older prepend grows the list at the FRONT while the tail is
// unchanged. `useLoadOlderOnScroll` owns the prepend restore via its index
// anchor (the single `scrollTop` writer). If this restore effect also ran
// its anchored `scrollBy` on the same commit, two writers would fight over
// `scrollTop`. So cede the prepend to the index path: refresh the tracked
// refs and bail before the anchored branch. (Append and in-window reflow
// leave the front id unchanged, so they fall through as before.)
const isPrepend =
firstMessage !== undefined &&
prevFirstId !== undefined &&
firstMessage.id !== prevFirstId &&
!newLatestArrived;
if (isPrepend) {
prevLastMessageIdRef.current = lastMessage?.id;
prevFirstMessageIdRef.current = firstMessage.id;
prevMessageCountRef.current = messages.length;
return;
}
// One-shot: an outbound send armed `scrollToBottomOnNextUpdate`. When the
// resulting append lands, snap to bottom regardless of the current anchor,
@@ -430,6 +645,7 @@ export function useAnchoredScroll({
setIsAtBottom(true);
setNewMessageCount(0);
prevLastMessageIdRef.current = lastMessage?.id;
prevFirstMessageIdRef.current = firstMessage?.id;
prevMessageCountRef.current = messages.length;
return;
}
@@ -455,11 +671,10 @@ export function useAnchoredScroll({
}
prevLastMessageIdRef.current = lastMessage?.id;
prevFirstMessageIdRef.current = firstMessage?.id;
prevMessageCountRef.current = messages.length;
}, [
isFetchingOlder,
isLoading,
layoutShiftKey,
messages,
onTargetReached,
scrollContainerRef,
@@ -468,102 +683,6 @@ export function useAnchoredScroll({
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;
let rearmFrame = 0;
// Once the timeline is scrollable, a parked sentinel must not keep
// re-firing: require it to actually leave and re-enter the preload band
// (a real scroll) before the next fetch. Without this, re-observing a
// still-intersecting sentinel synthesizes back-to-back fetches — the
// "spinner flashes a few times then a burst of rows" on reply-heavy
// channels. Auto-fill of a not-yet-scrollable page bypasses the gate.
let mustExitBandBeforeFetch = false;
const start = () => {
if (disposed) return;
observer = new IntersectionObserver(
([entry]) => {
if (!entry?.isIntersecting) {
mustExitBandBeforeFetch = false;
return;
}
if (disposed || fetchingOlderRef.current || mustExitBandBeforeFetch) {
return;
}
// One older fetch at a time. While a scroll-up is in flight, drop
// further triggers outright rather than queueing retries — fast
// scrolling otherwise stacks several sequential page loads. The
// post-fetch re-arm fires the next page only when the sentinel is
// still (or again) in the preload band.
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;
// If the prepend made the timeline scrollable, require a real
// scroll (sentinel leaving the band) before the next fetch.
// A still-too-short page keeps auto-filling.
mustExitBandBeforeFetch =
container.scrollHeight - container.clientHeight >
AT_BOTTOM_THRESHOLD_PX;
// Re-observe next frame so the fresh observer's callback sees the
// post-prepend intersection state.
rearmFrame = window.requestAnimationFrame(() => {
rearmFrame = 0;
start();
});
});
},
{ root: container, rootMargin: "200px 0px 0px 0px" },
);
observer.observe(sentinel);
};
start();
return () => {
disposed = true;
if (rearmFrame !== 0) {
window.cancelAnimationFrame(rearmFrame);
}
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
@@ -576,14 +695,35 @@ export function useAnchoredScroll({
// async embed expand, late font load, markdown that expands) silently
// pushes the reading row around.
// ---------------------------------------------------------------------------
// biome-ignore lint/correctness/useExhaustiveDependencies: channelId is a deliberate re-subscription trigger — the effect body reads only the stable refs, but on a channel switch the keyed scroll container remounts and contentRef.current becomes a fresh node, so the observer must disconnect from the previous channel's detached node and re-observe the live one.
React.useEffect(() => {
const content = contentRef.current;
if (!content || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => {
const container = scrollContainerRef.current;
if (!container) return;
// Cede entirely while a convergence loop owns scroll (jump to a
// windowed-out target). Mid-jump the anchor is transiently `at-bottom`
// — `computeAnchor` finds no crossing row until the virtualizer renders
// rows at the new offset — so an unconditional re-pin here would yank
// the in-flight jump down to the floor as rows measure. The convergence
// loop is the sole writer until it settles (mirrors the layout effect's
// `convergingTargetIdRef` bail).
if (convergingTargetIdRef.current !== null) return;
// Cede while the load-older index restore owns scroll. The prepended rows
// measuring late is exactly what grows `scrollHeight` and fires this
// observer; if it re-pinned here the anchor row is already windowed out,
// so the all-gone fallback would pin to the floor and stomp the index
// restore's correct offset. The index loop is the sole writer until it
// settles (mirrors the layout effect's isPrepend bail).
if (loadOlderRestoreInFlightRef.current) return;
const anchor = anchorRef.current;
if (anchor.kind === "at-bottom") {
// Stuck to bottom: re-pin to the new floor. Virtualizer measurement
// grows `scrollHeight` after the initial pin (rows below the fold
// measure a frame or two late) without any `messages` change to drive
// the layout effect, so this observer is the only thing that keeps the
// view glued to the bottom as content settles.
container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
return;
}
@@ -601,7 +741,7 @@ export function useAnchoredScroll({
});
observer.observe(content);
return () => observer.disconnect();
}, [contentRef, scrollContainerRef]);
}, [channelId, contentRef, scrollContainerRef]);
// ---------------------------------------------------------------------------
// Target message handling (deep link, jump-to-reply, etc.). Distinct from
@@ -619,6 +759,7 @@ export function useAnchoredScroll({
React.useEffect(() => {
if (!targetMessageId) {
handledTargetIdRef.current = null;
convergingTargetIdRef.current = null;
return;
}
if (handledTargetIdRef.current === targetMessageId || isLoading) return;
@@ -629,7 +770,23 @@ export function useAnchoredScroll({
const el = container.querySelector<HTMLElement>(
`[data-message-id="${targetMessageId}"]`,
);
if (!el) return; // Row not rendered yet; a later `messages` commit retries.
if (!el) {
// Row not in the DOM. In a virtualized list it may be windowed out and
// never render from a passive commit, so delegate to the convergence
// fallback: it drives the virtualizer to the target (warming up if a
// deep-link target is still being fetched in) and, on settle, centers +
// highlights it and fires `onTargetReached`. We mark the target handled
// here so this effect stops re-dispatching, but deliberately do NOT fire
// `onTargetReached` yet — clearing the route param now would cancel the
// in-flight target fetch the loop is waiting on. Without a fallback
// (thread panel), leave the target for a later `messages` commit.
const converge = convergeToTargetRef.current;
if (converge?.(targetMessageId)) {
handledTargetIdRef.current = targetMessageId;
convergingTargetIdRef.current = targetMessageId;
}
return;
}
handledTargetIdRef.current = targetMessageId;
scrollToMessageImperative(targetMessageId, { highlight: true });
onTargetReached?.(targetMessageId);
@@ -658,5 +815,8 @@ export function useAnchoredScroll({
scrollToBottom: scrollToBottomImperative,
scrollToBottomOnNextUpdate,
scrollToMessage: scrollToMessageImperative,
restoreScrollPosition,
setLoadOlderRestoreInFlight,
getAnchorIsAtBottom,
};
}

View File

@@ -0,0 +1,192 @@
import * as React from "react";
import {
type ConvergenceAlign,
CONVERGENCE_FRAME_CAP,
convergenceStep,
} from "@/features/messages/lib/scrollConvergence";
import type { ListVirtualizer } from "@/shared/ui/VirtualizedList";
/** Offset (px) within which the library is considered to have reached the target. */
const SETTLE_TOLERANCE_PX = 2;
type ConvergentScrollOptions = {
/** Live message-id -> item-index map, rebuilt with the flattened item stream. */
indexByMessageId: Map<string, number>;
/** Where the target should land in the viewport. */
align: ConvergenceAlign;
/** Fired on the settled frame once the target row has converged. */
onConverged?: (messageId: string) => void;
/** Fired when the loop stops without converging (target deleted, or frame cap). */
onAbandoned?: (messageId: string) => void;
};
type ConvergentScrollController = {
/**
* Begins a convergence loop toward `messageId`. Returns `true` (the loop
* always starts and owns the target). When the id isn't yet in the data — a
* deep-link target the route screen fetches asynchronously — the loop warms
* up by polling the live map, then converges once it lands; if it never
* lands within the frame cap it abandons (clears the highlight), the same
* terminal state as a target deleted mid-settle. A new call cancels any
* in-flight loop.
*/
scrollToMessage: (messageId: string) => boolean;
/** Cancels any in-flight convergence loop (e.g. on unmount or channel switch). */
cancel: () => void;
};
/**
* Drives @tanstack/react-virtual to settle on an off-screen message by id.
*
* The library already converges OFFSETS: one `scrollToIndex(i)` captures index
* `i` and its `reconcileScroll` rAF loop re-aims as rows mount and measure. But
* it chases the INDEX captured at call time — a prepend/delete mid-settle leaves
* it on the wrong row. This adapter closes that gap: each frame it re-resolves
* the target's CURRENT index from the live map (the pure `convergenceStep`
* reducer owns the decision) and re-issues `scrollToIndex` ONLY when the index
* moved. In steady state it issues nothing, so it never resets the library's
* internal stable-frame counter and the library settles in one frame.
*
* Settle detection is a trivial offset-equality check (NOT the convergence math,
* which the library owns): the measured offset for the current index is within
* tolerance of where the library would place it, and the offset is unchanged
* from the prior frame.
*/
export function useConvergentScrollToMessage(
getVirtualizer: () => ListVirtualizer | null,
{
indexByMessageId,
align,
onConverged,
onAbandoned,
}: ConvergentScrollOptions,
): ConvergentScrollController {
// Mirror inputs into refs so the rAF loop closure always reads live values
// without re-subscribing the loop each render.
const mapRef = React.useRef(indexByMessageId);
mapRef.current = indexByMessageId;
const alignRef = React.useRef(align);
alignRef.current = align;
const onConvergedRef = React.useRef(onConverged);
onConvergedRef.current = onConverged;
const onAbandonedRef = React.useRef(onAbandoned);
onAbandonedRef.current = onAbandoned;
const rafIdRef = React.useRef<number | null>(null);
const cancel = React.useCallback(() => {
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
}
}, []);
const scrollToMessage = React.useCallback(
(messageId: string) => {
cancel();
let lastIssuedIndex: number | null = null;
let previousOffset: number | null = null;
let framesUsed = 0;
// The id->index map is rebuilt one render after `messages` changes, so a
// freshly-spliced deep-link target (the route screen fetches it by id
// asynchronously) can be absent from the map on the frame this starts.
// Poll the live map during a bounded warmup instead of bailing; once it
// resolves, hand off to the normal convergence loop. If it never resolves
// within the cap, abandon — same terminal state as a deleted target.
let resolved = mapRef.current.has(messageId);
const frame = () => {
rafIdRef.current = null;
const virtualizer = getVirtualizer();
if (!virtualizer) {
return;
}
const currentIndex = mapRef.current.get(messageId);
if (!resolved) {
if (currentIndex === undefined) {
if (framesUsed + 1 >= CONVERGENCE_FRAME_CAP) {
onAbandonedRef.current?.(messageId);
return;
}
framesUsed += 1;
previousOffset = virtualizer.scrollOffset ?? 0;
rafIdRef.current = requestAnimationFrame(frame);
return;
}
resolved = true;
}
// The library has settled this frame when its offset reached the target
// index's offset (within tolerance) and stopped moving. `currentIndex`
// is re-read so a settle on a stale index never counts as converged.
let librarySettled = false;
let stalledOffTarget = false;
if (currentIndex !== undefined && lastIssuedIndex === currentIndex) {
const offset = virtualizer.scrollOffset ?? 0;
const target = virtualizer.getOffsetForIndex(
currentIndex,
alignRef.current,
);
const reachedTarget =
target !== undefined &&
Math.abs(offset - target[0]) <= SETTLE_TOLERANCE_PX;
const offsetStable =
previousOffset !== null &&
Math.abs(offset - previousOffset) <= SETTLE_TOLERANCE_PX;
librarySettled = reachedTarget && offsetStable;
stalledOffTarget = offsetStable && !reachedTarget;
previousOffset = offset;
} else {
previousOffset = virtualizer.scrollOffset ?? 0;
}
const decision = convergenceStep({
targetMessageId: messageId,
indexByMessageId: mapRef.current,
lastIssuedIndex,
librarySettled,
stalledOffTarget,
framesUsed,
});
if (
decision.nextIndex !== null &&
(decision.nextIndex !== lastIssuedIndex || decision.reissue)
) {
// Issue when the index moved (re-aim at the new row) OR the reducer
// asks for a same-index re-issue to kick an off-target stall. Reset
// `previousOffset` so the next frame's stability check restarts from
// the post-issue offset rather than treating the stall as settled.
virtualizer.scrollToIndex(decision.nextIndex, {
align: alignRef.current,
});
lastIssuedIndex = decision.nextIndex;
previousOffset = null;
}
if (decision.done) {
if (decision.converged) {
onConvergedRef.current?.(messageId);
} else {
onAbandonedRef.current?.(messageId);
}
return;
}
framesUsed += 1;
rafIdRef.current = requestAnimationFrame(frame);
};
rafIdRef.current = requestAnimationFrame(frame);
return true;
},
[cancel, getVirtualizer],
);
React.useEffect(() => cancel, [cancel]);
return { scrollToMessage, cancel };
}

View File

@@ -0,0 +1,318 @@
import * as React from "react";
import { CONVERGENCE_FRAME_CAP } from "@/features/messages/lib/scrollConvergence";
import type { ListVirtualizer } from "@/shared/ui/VirtualizedList";
type UseLoadOlderOnScrollOptions = {
fetchOlder?: () => Promise<void>;
hasOlderMessages: boolean;
isLoading: boolean;
restoreScrollPosition: (scrollTop: number) => void;
/**
* Brackets the index-restore loop's scroll ownership so the anchored hook's
* ResizeObserver cedes for the duration of the prepend. Without it, the
* prepended rows measuring late fire the observer with the windowed-out
* anchor and its all-gone fallback pins the view to the floor, stomping this
* loop's correct re-aim. Only the virtualized (index) path needs it — the
* non-virtualized path's restore is a single synchronous `scrollTop` write.
*/
setLoadOlderRestoreInFlight?: (inFlight: boolean) => void;
/**
* Live read of whether the user is stuck-to-bottom. If the user abandons the
* fetch by jumping to bottom WHILE the index restore owns scroll, the restore
* loop must re-aim at the bottom rather than the captured mid-history anchor
* — otherwise it lands the old anchor offset (short of the true floor) and the
* ceded ResizeObserver never chases the last rows down. Only meaningful with a
* virtualizer; the non-virtualized path restores synchronously.
*/
getAnchorIsAtBottom?: () => boolean;
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
sentinelRef: React.RefObject<HTMLDivElement | null>;
/**
* When the timeline is virtualized, a prepend shifts every index and a large
* one pushes the anchored row out of the window before it can be re-measured.
* Supplying the virtualizer switches to an index anchor: we hold the
* first-visible row across the prepend by re-aiming `scrollToIndex` at its new
* index (resolved from `indexByMessageId`) until the library settles it.
*/
virtualizer?: {
getVirtualizer: () => ListVirtualizer | null;
indexByMessageId: Map<string, number>;
itemCount: number;
/** Live (non-deferred) message count for growth detection during load-older. */
liveMessageCount?: number;
} | null;
};
/**
* The scroll offset the prepend-restore loop should hold this frame. Returns
* `undefined` when there's nothing to aim at (no virtualizer, no growth yet, or
* the captured anchor row isn't resolvable). Resolved off the virtualizer's
* live measurement each frame so the loop chases the offset as prepended rows
* grow `getTotalSize()`.
* - `abandonedToBottom`: the user jumped to bottom mid-prepend → the last
* row's END offset (the true floor), not the stale mid-history anchor.
* - otherwise: the captured first-visible row's START offset minus the gap
* that was above it, holding the reader's eye-line across the prepend.
*/
function resolveTarget({
instance,
abandonedToBottom,
lastIndex,
newIndex,
anchorTop,
}: {
instance: ListVirtualizer | null;
abandonedToBottom: boolean;
lastIndex: number;
newIndex: number | undefined;
anchorTop: number;
}): number | undefined {
if (!instance) return undefined;
if (abandonedToBottom) {
if (lastIndex < 0) return undefined;
return instance.getOffsetForIndex(lastIndex, "end")?.[0];
}
if (newIndex === undefined) return undefined;
const start = instance.getOffsetForIndex(newIndex, "start");
return start === undefined ? undefined : start[0] - anchorTop;
}
/**
* 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,
setLoadOlderRestoreInFlight,
getAnchorIsAtBottom,
scrollContainerRef,
sentinelRef,
virtualizer = null,
}: UseLoadOlderOnScrollOptions) {
const restoreScrollPositionRef = React.useRef(restoreScrollPosition);
React.useEffect(() => {
restoreScrollPositionRef.current = restoreScrollPosition;
});
// Mirror the cede setter so the long-lived Intersection observer reads the
// live callback without re-subscribing (same rationale as the restore ref).
const setInFlightRef = React.useRef(setLoadOlderRestoreInFlight);
React.useEffect(() => {
setInFlightRef.current = setLoadOlderRestoreInFlight;
});
// Mirror the at-bottom getter so the prepend loop reads the live abandon
// state without re-subscribing the long-lived observer per render.
const getAnchorIsAtBottomRef = React.useRef(getAnchorIsAtBottom);
React.useEffect(() => {
getAnchorIsAtBottomRef.current = getAnchorIsAtBottom;
});
// Mirror the virtualizer option into a ref so the long-lived Intersection
// observer reads the live getter + count without re-subscribing per render.
const virtualizerRef = React.useRef(virtualizer);
virtualizerRef.current = virtualizer;
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 virt = virtualizerRef.current;
if (virt) {
// Hold the first VISIBLE row across the prepend. After N older rows
// are prepended the anchored row's INDEX shifts by N and — with
// scrollTop unchanged near the top — it's pushed below the window
// and recycled out of the DOM, so a pure DOM re-read can't find it.
// We therefore drive the virtualizer: capture the row's id + its top
// offset in the viewport now, and after the prepend re-aim
// `scrollToIndex(newIndex, "start")` each frame (re-issued only when
// the resolved index moves, so the library's internal settle loop is
// never reset — same single-issue discipline as the convergence
// adapter). `scrollToIndex` re-aims internally as rows mount and
// measure, landing the row's TOP at the viewport top; once it settles
// we apply the captured intra-viewport gap with ONE scrollTop write.
// Single writer throughout: one mechanism re-aims, the gap is a final
// one-shot, never an overlapping second target.
const instance = virt.getVirtualizer();
const container = scrollContainerRef.current;
// Use liveMessageCount (live, non-deferred) rather than itemCount
// or instance.options.count — both are derived from deferredMessages
// and are stale during a fetch, so the growth check would never fire.
const previousCount = virt.liveMessageCount ?? virt.itemCount;
void fetchOlder().then(() => {
// Claim scroll ownership for the whole re-aim window so the
// anchored hook's ResizeObserver cedes while the prepended rows
// measure (released at every exit below via `finishPrepend`).
setInFlightRef.current?.(true);
// Capture the anchor at fetch-RESOLVE time, not sentinel-fire
// time: the history request can be in flight for a while and the
// user may keep scrolling during it (the e2e scrolls further down
// mid-fetch). The row+offset we must preserve is wherever the
// reader actually is the instant before the prepend commits, read
// from the live DOM here while every visible row is still mounted.
const containerRect = container?.getBoundingClientRect();
const containerTop = containerRect?.top ?? 0;
const containerBottom = containerRect?.bottom ?? 0;
// First row intersecting the viewport — the reader's eye-line row.
// Geometry matches the test's getFirstVisibleMessage exactly: its
// bottom is below the viewport top and its top is above the
// viewport bottom.
const anchorRow = container
? Array.from(
container.querySelectorAll<HTMLElement>(
"[data-message-id]",
),
).find((row) => {
const rect = row.getBoundingClientRect();
return (
rect.bottom > containerTop && rect.top < containerBottom
);
})
: undefined;
const anchorId = anchorRow?.dataset.messageId ?? null;
// The anchored row's top relative to the viewport top — held
// constant across the prepend.
const anchorTop = anchorRow
? anchorRow.getBoundingClientRect().top - containerTop
: 0;
// The timeline drives its rows off a `useDeferredValue` of the
// message list, so the prepended items commit on a LOW-priority
// render that can land several frames after `fetchOlder` resolves.
// Poll rAF until the live id->index map actually shifts the anchor
// (the prepend is observable), capped so an empty fetch can't spin.
const maxFrames = CONVERGENCE_FRAME_CAP;
let frame = 0;
let lastTarget: number | null = null;
let stableFrames = 0;
// Release scroll ownership (re-enabling the ResizeObserver) and
// re-arm the sentinel observer. Called at both loop exits.
const finishPrepend = () => {
setInFlightRef.current?.(false);
observe();
};
const waitForPrepend = () => {
const after = virtualizerRef.current;
const grew =
(after?.liveMessageCount ?? previousCount) > previousCount;
// Resolve this frame's target offset. Two cases, one mechanism:
// - Abandon: the user jumped to bottom while this loop owned
// scroll. Hold the BOTTOM (last row's end offset), not the
// captured mid-history anchor — that old offset sits short of
// the true floor and would strand the view there, since the
// ResizeObserver re-pin is ceded to this loop for the whole
// window.
// - Normal: hold the captured first-visible row at its viewport
// gap (its start offset minus the gap that was above it).
// Either way we drive `scrollToOffset` — NOT `scrollToIndex` — so
// the library's reconcile holds a FIXED offset instead of
// re-resolving each frame and overwriting it. As the prepended
// rows measure, `getOffsetForIndex` grows and we recompute and
// re-issue. Re-issue ONLY when the target moves — re-issuing an
// unchanged offset resets the library's stable-frame counter and
// spins. Same single-issue discipline as the convergence adapter.
const abandonedToBottom =
getAnchorIsAtBottomRef.current?.() ?? false;
const newIndex =
anchorId !== null
? after?.indexByMessageId.get(anchorId)
: undefined;
const target = resolveTarget({
instance: grew ? instance : null,
abandonedToBottom,
lastIndex: (after?.liveMessageCount ?? previousCount) - 1,
newIndex,
anchorTop,
});
if (instance && target !== undefined) {
if (target !== lastTarget) {
instance.scrollToOffset(target, { align: "start" });
lastTarget = target;
stableFrames = 0;
} else if ((instance.scrollOffset ?? 0) >= target) {
// Reached the target and it stopped moving (measurement
// settled). `>=` not `===`: the abandon path aims at the
// last possible offset, which the container clamps, so the
// realized `scrollOffset` can sit at-or-past it. Two stable
// frames guard against ending before the last row measures.
stableFrames += 1;
if (stableFrames >= 2) {
finishPrepend();
return;
}
}
}
frame += 1;
if (frame >= maxFrames) {
finishPrepend();
return;
}
requestAnimationFrame(waitForPrepend);
};
requestAnimationFrame(waitForPrepend);
});
return;
}
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,5 +1,6 @@
import { type Virtualizer, useVirtualizer } from "@tanstack/react-virtual";
import * as React from "react";
import { createPortal } from "react-dom";
import { cn } from "@/shared/lib/cn";
@@ -16,8 +17,15 @@ export type ListVirtualizer = Virtualizer<HTMLElement, Element>;
* dynamic sizing automatically.
*
* Supports:
* (a) Optional non-virtualized sticky-header slot rendered above the virtual
* rows inside the scroll container (for PulseView's sticky composer, etc).
* (a) Optional floating header fed the topmost visible row index. It is
* portaled into a caller-owned container OUTSIDE the scroll element
* (`headerOverlayRef`) so it pins to a fixed viewport offset regardless of
* scroll position — a `position: sticky` header inside the scroll element
* drifts at scrollTop 0, where it reveals its natural flow offset instead
* of the pinned offset. The render fn still re-runs with the virtualizer on
* every scroll (the absolutely-positioned rows cannot stay `position:
* sticky` once they leave the virtual window), but the re-render stays
* localized here rather than forcing the caller to re-render per scroll.
* (b) Optional externally-owned scroll container — pass `scrollRef` when the
* caller already owns the scrolling element (a surface that shares its
* scroll region with non-row siblings). When omitted, VirtualizedList
@@ -32,8 +40,20 @@ type VirtualizedListProps<T> = {
renderItem: (item: T, index: number) => React.ReactNode;
/** Estimated row height in px — used before measurement. */
estimateSize?: number;
/** Optional non-virtualized content rendered above the virtual rows (sticky headers, etc). */
stickyHeader?: React.ReactNode;
/**
* Optional floating header rendered with the index of the topmost visible
* row (so it can label what's on screen). When `headerOverlayRef` points at a
* mounted element, the result is portaled there — a non-scrolling container
* outside the scroll element — so it pins to a fixed offset and cannot drift
* with scroll position. `null` before the first row is in view.
*/
stickyHeader?: (topVisibleIndex: number | null) => React.ReactNode;
/**
* Portal target for `stickyHeader`. Must sit OUTSIDE the scroll element (a
* `position: sticky` header inside it drifts at scrollTop 0). The header is
* portaled here only once the ref attaches.
*/
headerOverlayRef?: React.RefObject<HTMLElement | null>;
/**
* Externally-owned scroll container. When provided, no internal scroll
* container is rendered — the caller's element scrolls and is measured.
@@ -55,6 +75,7 @@ export function VirtualizedList<T>({
renderItem,
estimateSize = 80,
stickyHeader,
headerOverlayRef,
scrollRef,
className,
innerClassName,
@@ -100,19 +121,37 @@ export function VirtualizedList<T>({
scrollMargin,
});
React.useEffect(() => {
// Register in a layout effect, not a passive one: when this list remounts on
// channel switch the parent's mount-pin runs in a layout effect on the same
// commit, and child layout effects fire before parent layout effects. A
// passive effect would publish the fresh virtualizer too late and the parent
// would pin against the previous channel's stale instance.
React.useLayoutEffect(() => {
onVirtualizer?.(virtualizer);
}, [onVirtualizer, virtualizer]);
const virtualItems = virtualizer.getVirtualItems();
// The header portals into `headerOverlayRef`, which the parent mounts before
// this child — so it is populated on first commit. The dependency-free state
// flip re-renders once after mount to cover the rare case where the parent
// attaches the ref in the same commit as our first paint.
const [headerHost, setHeaderHost] = React.useState<HTMLElement | null>(null);
React.useEffect(() => {
setHeaderHost(headerOverlayRef?.current ?? null);
}, [headerOverlayRef]);
const header = stickyHeader?.(virtualItems[0]?.index ?? null);
const content = (
<>
{stickyHeader}
{headerHost && header ? createPortal(header, headerHost) : null}
<div
className={cn("relative w-full", innerClassName)}
ref={spacerRef}
style={{ height: `${virtualizer.getTotalSize()}px` }}
>
{virtualizer.getVirtualItems().map((virtualRow) => (
{virtualItems.map((virtualRow) => (
<div
data-index={virtualRow.index}
key={virtualRow.key}

View File

@@ -1569,6 +1569,37 @@ const mockChannels: MockChannel[] = [
createMockMember(MOCK_IDENTITY_PUBKEY, "member", 700),
],
}),
// Deep history channel for the load-older-under-virtualization E2E. Seeded
// with more messages than CHANNEL_HISTORY_LIMIT (300) so the initial load
// windows to the newest page and a `fetchOlder` (until-cursor) prepend has
// genuinely older rows to add — exercising the scroll-restore anchor under
// virtualization. Its own channel so existing channels' row-index and unread
// assertions stay undisturbed.
createMockChannel({
id: "feedf00d-0000-4000-8000-000000000007",
name: "deep-history",
channel_type: "stream",
visibility: "open",
description: "Channel with paginated history for load-older tests",
topic: null,
purpose: null,
last_message_at: isoMinutesAgo(1),
archived_at: null,
created_by: ALICE_PUBKEY,
topic_set_by: null,
topic_set_at: null,
purpose_set_by: null,
purpose_set_at: null,
topic_required: false,
max_members: null,
nip29_group_id: null,
created_minutes_ago: 2000,
updated_minutes_ago: 1,
members: [
createMockMember(ALICE_PUBKEY, "owner", 2000),
createMockMember(MOCK_IDENTITY_PUBKEY, "member", 1900),
],
}),
];
const mockMessages = new Map<string, RelayEvent[]>();
@@ -2374,7 +2405,24 @@ function getMockMessageStore(channelId: string): RelayEvent[] {
sig: "mocksig".repeat(20).slice(0, 128),
},
]
: [];
: channelId === "feedf00d-0000-4000-8000-000000000007"
? // 600 messages > CHANNEL_HISTORY_LIMIT (300): the initial load
// windows to the newest 300, leaving 300 older behind the until
// cursor — enough for several full fetchOlder pages (batch 100),
// so the load-older anchor restore is exercised across REPEATED
// prepend cycles, not a single lucky pass. created_at increases
// with index (oldest first) so message N+1 is newer than N — the
// anchor restores the first-visible row across each prepend.
Array.from({ length: 600 }, (_, index) => ({
id: `mock-deep-history-${index}`,
pubkey: index % 2 === 0 ? ALICE_PUBKEY : MOCK_IDENTITY_PUBKEY,
created_at: Math.floor(Date.now() / 1000) - (600 - index) * 60,
kind: 9,
tags: [["h", channelId]],
content: `Deep history message #${index}`,
sig: "mocksig".repeat(20).slice(0, 128),
}))
: [];
mockMessages.set(channelId, seeded);
return seeded;

View File

@@ -279,7 +279,7 @@ async function expectSameLeftInset(
expect(Math.abs(firstBox.x - secondBox.x)).toBeLessThanOrEqual(4);
}
async function expectIntroBalancedAroundDayDivider(
async function expectIntroSpacedAboveDayDivider(
page: import("@playwright/test").Page,
introTestId: string,
) {
@@ -300,7 +300,17 @@ async function expectIntroBalancedAroundDayDivider(
const gapAboveDivider = dividerBox.y - (introBox.y + introBox.height);
const gapBelowDivider = messageBox.y - (dividerBox.y + dividerBox.height);
expect(Math.abs(gapAboveDivider - gapBelowDivider)).toBeLessThanOrEqual(1);
// The intro is a flex sibling above the timeline; the day divider and first
// message-row are virtualized items positioned by translateY inside the
// scroll container. The intro -> divider gap is the wrapper flex spacing the
// layout controls (8px, stable), so guard THAT with a tight band — a layout
// regression that collapses or balloons it fails here. The divider -> message
// gap is NOT a layout-spacing contract: virtualized rows are positioned
// back-to-back (no inter-item gap), so it is ~0 by construction plus
// MessageRow avatar/font render jitter, genuinely variable run-to-run. Assert
// only non-overlap on it (reading order: intro, divider, then message).
expect(Math.abs(gapAboveDivider - 8)).toBeLessThanOrEqual(2);
expect(gapBelowDivider).toBeGreaterThanOrEqual(0);
}
async function expectIntroActionCardLayout(
@@ -1157,7 +1167,7 @@ test("sidebar clears unread indicator after opening a DM", async ({ page }) => {
"Unread update for the DM",
);
await expectSameLeftInset(page, "message-dm-intro", "message-row");
await expectIntroBalancedAroundDayDivider(page, "message-dm-intro");
await expectIntroSpacedAboveDayDivider(page, "message-dm-intro");
await expect(page.getByTestId("channel-unread-alice-tyler")).toHaveCount(0);
});

View File

@@ -0,0 +1,166 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
/**
* Cold-channel-switch longtask harness.
*
* This is the instrument the timeline-virtualization acceptance gate is defined
* against: it measures the main-thread blocking cost of the FIRST switch into a
* deep channel (600 seeded messages, windowed to the 300-row ceiling on cold
* load). main builds every windowed row synchronously on first mount, so that
* mount is the beachball; virtualization renders only the visible window, so the
* mount cost is bounded and independent of channel depth.
*
* WHY LONGTASKS, NOT LAYOUT METRICS: the felt jank is the main thread being
* blocked past the ~50ms frame-budget wall during the mount. PerformanceObserver
* `longtask` entries are exactly the >50ms main-thread tasks the browser itself
* flags — the honest, engine-level signal for "the UI froze". We report the
* LONGEST single longtask (the worst freeze) and the TOTAL longtask time across
* the switch window (the split-task guard axis — many medium tasks can hide the
* same total cost a single long one would show).
*
* WHY 4x CPU THROTTLE: headless Chromium on dev hardware is far faster than the
* target machines; 4x throttle via CDP brings the mount cost into a regime where
* a 300-row synchronous build actually crosses the frame-budget wall, so the
* measurement discriminates. The throttle and machine cancel in the B-vs-header
* delta gate, so absolute ms are not portable but the delta is.
*
* COLD = warm `general` first, then the FIRST switch into `deep-history`. A warm
* re-entry hits cached render state and does not reproduce the cold mount cost.
* Each of the 5 runs re-warms `general` so every deep-history switch is cold.
*
* SCOPE LIMIT: this measures Chromium main-thread longtasks under throttle. It
* does NOT measure the WKWebView compositor feel on the shipped Tauri shell —
* that is a separate real-wheel pass.
*
* Run it:
* pnpm build && npx playwright test --config=playwright.perf.config.ts \
* cold-switch-longtask.perf.ts
*/
const RUNS = 5;
const THROTTLE_RATE = 4;
type RunResult = { longest: number; total: number; count: number };
function median(values: number[]): number {
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0
? (sorted[mid - 1] + sorted[mid]) / 2
: sorted[mid];
}
test("MEASURE: cold-switch longtask cost into deep-history at the 300 ceiling", async ({
page,
}) => {
test.setTimeout(120_000);
await installMockBridge(page);
await page.goto("/");
await page.waitForFunction(
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
);
// Arm a longtask observer that buffers into a window array we can read and
// reset per run. `buffered: true` catches tasks queued before the read.
await page.addInitScript(() => {
const store = window as unknown as { __LONGTASKS__?: number[] };
store.__LONGTASKS__ = [];
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
store.__LONGTASKS__?.push(entry.duration);
}
}).observe({ type: "longtask", buffered: true });
});
// addInitScript only applies on the next navigation, so reload to arm it.
await page.reload();
await page.waitForFunction(
() =>
typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" &&
Array.isArray(
(window as unknown as { __LONGTASKS__?: number[] }).__LONGTASKS__,
),
);
const client = await page.context().newCDPSession(page);
await client.send("Emulation.setCPUThrottlingRate", { rate: THROTTLE_RATE });
const timeline = page.getByTestId("message-timeline");
const results: RunResult[] = [];
for (let run = 0; run < RUNS; run += 1) {
// Warm `general` so the deep-history switch that follows is a cold first
// entry, not a warm re-render of cached state.
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
// Clear the buffer immediately before the cold switch so only the switch's
// longtasks are attributed to this run.
await page.evaluate(() => {
(window as unknown as { __LONGTASKS__: number[] }).__LONGTASKS__ = [];
});
// The cold switch: first entry into the 600-message channel. The cold load
// windows to the newest 300 and mounts them.
await page.getByTestId("channel-deep-history").click();
await expect(page.getByTestId("chat-title")).toHaveText("deep-history");
await expect(
page.locator('[data-message-id^="mock-deep-history-"]').first(),
).toBeVisible();
// Let any post-mount longtasks (anchor settle, sticky handoff) flush before
// reading — they are part of the switch cost.
await page.waitForTimeout(300);
const tasks = await page.evaluate(
() =>
(window as unknown as { __LONGTASKS__: number[] }).__LONGTASKS__ ?? [],
);
results.push({
longest: tasks.length ? Math.max(...tasks) : 0,
total: tasks.reduce((sum, d) => sum + d, 0),
count: tasks.length,
});
}
await client.send("Emulation.setCPUThrottlingRate", { rate: 1 });
const longests = results.map((r) => r.longest);
const totals = results.map((r) => r.total);
const medianLongest = median(longests);
const minLongest = Math.min(...longests);
const maxLongest = Math.max(...longests);
const spread = maxLongest - minLongest;
const medianTotal = median(totals);
/* eslint-disable no-console */
console.log(
"\n=== COLD-SWITCH LONGTASK BASELINE (deep-history, 300 ceiling) ===",
);
console.log(`CPU throttle: ${THROTTLE_RATE}x`);
console.log(`runs: ${RUNS}`);
console.log(
`per-run longest-longtask (ms): [${longests.map((v) => v.toFixed(1)).join(", ")}]`,
);
console.log(
`per-run total-longtask (ms): [${totals.map((v) => v.toFixed(1)).join(", ")}]`,
);
console.log(
`per-run longtask count: [${results.map((r) => r.count).join(", ")}]`,
);
console.log(`MEDIAN longest-longtask: ${medianLongest.toFixed(1)}ms`);
console.log(
` spread (max - min): ${spread.toFixed(1)}ms (min ${minLongest.toFixed(1)}, max ${maxLongest.toFixed(1)})`,
);
console.log(`MEDIAN total-longtask-in-window: ${medianTotal.toFixed(1)}ms`);
console.log("(>50ms single task is a dropped-frame freeze the user feels)");
console.log(
"=================================================================\n",
);
/* eslint-enable no-console */
// Instrument, not a gate — confirm the switch actually exercised the mount
// under throttle (some longtask work happened on at least one run).
expect(results.some((r) => r.count > 0)).toBe(true);
});

View File

@@ -175,11 +175,32 @@ test("reconnect backfills more missed channel messages than the live subscriptio
}));
await emitMockMessages(page, missedMessages);
await expect(page.getByTestId("message-timeline")).toContainText(
"reconnect e2e missed 001",
{ timeout: 15_000 },
);
await expect(page.getByTestId("message-timeline")).toContainText(
"reconnect e2e missed 260",
);
// The newest backfilled message renders at the bottom once the reconnect
// catch-up settles.
const timeline = page.getByTestId("message-timeline");
await expect(timeline).toContainText("reconnect e2e missed 260", {
timeout: 15_000,
});
// The virtualized timeline windows the oldest backfilled rows out of the DOM
// while the user sits at the bottom, so the backfill depth can't be asserted
// by expecting all 260 rows to be mounted at once. Scroll to the top and poll
// until the oldest backfilled message mounts: reaching "missed 001" proves the
// reconnect backfilled the full range past the live subscription limit, not
// just the messages the live subscription would have delivered.
await expect
.poll(
async () => {
await timeline.evaluate((element) => {
const scrollable = element as HTMLDivElement;
scrollable.scrollTop = 0;
scrollable.dispatchEvent(new Event("scroll", { bubbles: true }));
});
return timeline.evaluate((element) =>
(element.textContent ?? "").includes("reconnect e2e missed 001"),
);
},
{ timeout: 15_000 },
)
.toBe(true);
});

View File

@@ -61,6 +61,30 @@ async function getMessagePosition(
}, messageId);
}
// The floating active-day header (ActiveDayHeader) is portaled OUTSIDE the
// scroll container into a non-scrolling overlay, so it pins to a fixed offset
// instead of drifting at scrollTop 0. We read its label + offset relative to
// the scroll container's top so a test can assert it neither drifts nor flips
// day as older history prepends above the anchor.
async function getActiveDayHeader(page: import("@playwright/test").Page) {
return page.evaluate(() => {
const timeline = document.querySelector<HTMLElement>(
'[data-testid="message-timeline"]',
);
const pill = document.querySelector<HTMLElement>(
'[data-testid="message-timeline-active-day-header"]',
);
if (!timeline || !pill) {
return null;
}
return {
label: pill.dataset.dayLabel ?? pill.textContent ?? "",
top:
pill.getBoundingClientRect().top - timeline.getBoundingClientRect().top,
};
});
}
test("first channel load holds skeleton instead of showing older-history spinner", async ({
page,
}) => {
@@ -111,47 +135,25 @@ test("first channel load holds skeleton instead of showing older-history spinner
test("preserves user scroll while older channel history loads", async ({
page,
}) => {
}, testInfo) => {
testInfo.setTimeout(60_000);
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",
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
);
await page.evaluate(() => {
for (let index = 0; index < 40; index += 1) {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: `visible current ${index}\nsecond line ${index}`,
});
}
window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__?.({
channelName: "general",
count: 600,
lineCount: 3,
});
});
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// Use the `deep-history` channel: its store is seeded with 600 messages,
// more than CHANNEL_HISTORY_LIMIT (300, hooks.ts), so the cold load windows
// to the newest 300 and leaves ~300 genuinely older messages behind the
// `until` cursor. A shallow seed (store < 300) is fully drained by the cold
// load, so the wheel `fetchOlder` returns only already-cached duplicates that
// dedup to zero net growth -- the anchor never has a real prepend to hold and
// the assertion would measure virtualizer re-measure, not scroll preservation.
await page.getByTestId("channel-deep-history").click();
await expect(page.getByTestId("chat-title")).toHaveText("deep-history");
const timeline = page.getByTestId("message-timeline");
await expect(timeline).toContainText("visible current 39");
// Initial load should receive enough history to make the page scrollable.
// Delay only the next history request, so the test isolates pagination while
// the user is actively scrolling.
await page.evaluate(() => {
window.__BUZZ_E2E__ = {
...window.__BUZZ_E2E__,
mock: {
...window.__BUZZ_E2E__?.mock,
historyDelayMs: 1_000,
},
};
});
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
await page.waitForFunction(() => {
const element = document.querySelector(
'[data-testid="message-timeline"]',
@@ -159,54 +161,134 @@ test("preserves user scroll while older channel history loads", async ({
return element && element.scrollHeight > element.clientHeight + 1000;
});
// Move away from the bottom before jumping near the top; otherwise the
// timeline's sticky-bottom guard can intentionally pin the first upward jump.
const beforeFetch = await getTimelineMetrics(page);
await timeline.evaluate((element) => {
const timelineElement = element as HTMLDivElement;
timelineElement.scrollTop = timelineElement.scrollHeight;
timelineElement.dispatchEvent(new Event("scroll", { bubbles: true }));
});
await page.waitForTimeout(50);
// The lowest "Deep history message #N" index currently rendered. The seed
// numbers messages 0 (oldest) .. 599 (newest), so a smaller index = older.
const oldestRenderedIndex = () =>
timeline.evaluate((element) => {
let min = Number.POSITIVE_INFINITY;
for (const row of (
element as HTMLDivElement
).querySelectorAll<HTMLElement>("[data-message-id]")) {
const match = row.textContent?.match(/#(\d+)/);
if (match) min = Math.min(min, Number(match[1]));
}
return Number.isFinite(min) ? min : null;
});
const nearTop = await timeline.evaluate((element) => {
const timelineElement = element as HTMLDivElement;
timelineElement.scrollTop = 180;
timelineElement.dispatchEvent(new Event("scroll", { bubbles: true }));
return timelineElement.scrollTop;
});
expect(nearTop).toBeLessThan(260);
// PHASE 1 -- walk into mid-history with NO history delay. Each fetchOlder
// resolves instantly, the prepend lands, the oldest-rendered index advances,
// and the next wheel re-enters a fresh top sentinel. This sustained climb is
// how a deep seed reaches the sentinel under real wheel (the `count:2100`
// sibling relies on the same mechanic). We stop in mid-history -- not at the
// top -- so a genuine older page still sits behind the `until` cursor for
// phase 2 to fetch, and so the anchor we hold has older content arriving
// ABOVE it (the actual scroll-preservation scenario, not the at-top edge).
await timeline.hover();
let deepest = Number.POSITIVE_INFINITY;
let stallStreak = 0;
for (let attempt = 0; attempt < 120 && deepest > 250; attempt += 1) {
await page.mouse.wheel(0, -4000);
await page.waitForTimeout(70);
const current = await oldestRenderedIndex();
if (current !== null && current < deepest) {
deepest = current;
stallStreak = 0;
} else {
stallStreak += 1;
if (stallStreak > 20) break;
}
}
// Confirm phase 1 actually paginated into mid-history -- if it never climbed
// off the newest window the rest of the test is meaningless.
expect(deepest).toBeLessThan(400);
await page.waitForTimeout(100);
const duringFetch = await timeline.evaluate((element) => {
const timelineElement = element as HTMLDivElement;
timelineElement.scrollTop = timelineElement.scrollTop + 160;
timelineElement.dispatchEvent(new Event("scroll", { bubbles: true }));
return timelineElement.scrollTop;
// PHASE 2 -- now delay the next history page so it stays in flight long
// enough to observe the anchor across the landing. historyDelayMs is read
// live by the bridge, so toggling it here applies to the next fetch only.
await page.evaluate(() => {
window.__BUZZ_E2E__ = {
...window.__BUZZ_E2E__,
mock: { ...window.__BUZZ_E2E__?.mock, historyDelayMs: 1_000 },
};
(
window as unknown as { __HISTORY_INFLIGHT__?: number }
).__HISTORY_INFLIGHT__ = 0;
});
expect(duringFetch).toBeGreaterThan(nearTop);
const anchorDuringFetch = await getFirstVisibleMessage(page);
expect(anchorDuringFetch).not.toBeNull();
const inflightCount = () =>
page.evaluate(
() =>
(window as unknown as { __HISTORY_INFLIGHT__?: number })
.__HISTORY_INFLIGHT__ ?? 0,
);
// Snapshot the oldest rendered index BEFORE firing the delayed page, so the
// poll's growth gate can require a genuinely NEW older index after it lands.
const oldestBeforeLanding = await oldestRenderedIndex();
expect(oldestBeforeLanding).not.toBeNull();
// One wheel tick to fire the delayed older-history page.
for (let attempt = 0; attempt < 50; attempt += 1) {
if ((await inflightCount()) > 0) break;
await page.mouse.wheel(0, -4000);
await page.waitForTimeout(50);
}
expect(await inflightCount()).toBeGreaterThan(0);
// Capture the first-visible row id AFTER the fire wheel but WHILE the page is
// still in flight (the prepend lands ~historyDelayMs later). The fire wheel
// moves the viewport, so the anchor must be read at this settled in-flight
// position -- a row captured before the fire wheel can scroll out of the
// virtualized window before the prepend lands. This row exists before the
// prepend and is the one the restore must hold.
const anchorBeforeLanding = await getFirstVisibleMessage(page);
expect(anchorBeforeLanding).not.toBeNull();
// The floating active-day header is pinned at the viewport top and reflects
// the topmost visible row's day. As older history prepends ABOVE the anchor,
// the header must neither move (it's pinned, not part of the prepended flow)
// nor flip to a different day (the visible row's day is unchanged). Capture
// its label + pinned offset alongside the anchor so the same landing that
// proves the message anchor holds also proves the header holds.
const headerBeforeLanding = await getActiveDayHeader(page);
expect(headerBeforeLanding).not.toBeNull();
// The poll must observe the anchor holding AS a genuine older page lands
// above it. It only counts a drift reading once BOTH hold in the same sample:
// the delayed fetch has RESOLVED (inflight back to 0) AND it brought a real
// older index that was not rendered before the prepend (proof the page was
// not a duplicate-only fetch). Until then it returns Infinity and keeps
// sampling, so it cannot pass against the settled pre-landing window.
await expect
.poll(
async () => {
const [anchor, metrics] = await Promise.all([
getMessagePosition(page, anchorDuringFetch?.id ?? ""),
getTimelineMetrics(page),
const [inflight, oldestNow, anchor] = await Promise.all([
inflightCount(),
oldestRenderedIndex(),
getMessagePosition(page, anchorBeforeLanding?.id ?? ""),
]);
if (metrics.scrollHeight <= beforeFetch.scrollHeight + 1000) {
const landed =
inflight === 0 &&
oldestNow !== null &&
oldestNow < (oldestBeforeLanding ?? 0);
if (!landed || !anchor) {
return Number.POSITIVE_INFINITY;
}
return anchor
? Math.abs(anchor.top - (anchorDuringFetch?.top ?? 0))
: Number.POSITIVE_INFINITY;
return Math.abs(anchor.top - (anchorBeforeLanding?.top ?? 0));
},
{
timeout: 3_000,
},
)
.toBeLessThanOrEqual(2);
// The older page has now landed (the poll above only resolves on it). The
// floating day-header must not have drifted or changed day across it.
const headerAfterLanding = await getActiveDayHeader(page);
expect(headerAfterLanding).not.toBeNull();
expect(headerAfterLanding?.label).toBe(headerBeforeLanding?.label);
expect(
Math.abs((headerAfterLanding?.top ?? 0) - (headerBeforeLanding?.top ?? 0)),
).toBeLessThanOrEqual(2);
});
// Criterion 2: abandon-to-bottom mid-fetch.
@@ -329,8 +411,16 @@ test("does not teleport upward when user abandons fetch by jumping to bottom", a
// Sanity: we did move away from the bottom and the prepend has NOT yet
// resolved (otherwise the abandon scenario doesn't exist).
expect(duringFetch.scrollTop).toBeLessThan(500);
expect(duringFetch.scrollHeight).toBeLessThanOrEqual(
baseline.scrollHeight + 100,
// "Prepend not yet resolved" tested directly via the in-flight indicator the
// suite already keys on (it renders while `isFetchingOlder` OR the prepended
// rows have not painted yet — line 127 asserts its ABSENCE at idle load). The
// older `scrollHeight <= baseline + 100` proxy assumed scrollHeight only
// changes when DOM rows are added — true for a non-virtualized list, but a
// virtualizer grows `getTotalSize()` purely from lazy row measurement (no
// prepend), so that proxy false-fails here. This signal is agnostic to how
// scrollHeight evolves.
await expect(page.getByTestId("message-timeline-fetching-older")).toHaveCount(
1,
);
// Abandon: jump back to bottom while the fetch is still in flight.
@@ -506,9 +596,13 @@ test("deep-link to a message in older history scrolls and highlights it", async
// that sits well outside the initial-render window.
const prependedIds: string[] = await page.evaluate(() => {
for (let index = 0; index < 40; index += 1) {
// Monotonic createdAt so `visible current 39` (seeded last) sorts to the
// genuine last row rather than a random UUID-tiebreak position; matches
// the channel-intro seed precedent. The :630 assertion stays untouched.
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: `visible current ${index}\nsecond line ${index}`,
createdAt: 1_700_000_000 + index,
});
}
const events = window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__?.({
@@ -569,28 +663,75 @@ test("deep-link to a message in older history scrolls and highlights it", async
const targetRow = timeline.locator(`[data-message-id="${targetId}"]`);
await expect(targetRow).toBeVisible({ timeout: 5_000 });
// (b) Geometry: target row sits inside the timeline viewport AND near
// the vertical center. "Near center" is defined as within one row-height
// worth of slack of half the timeline's clientHeight -- generous enough
// to tolerate centering implementation choices but tight enough to catch
// "row is technically visible but at the top edge" regressions.
// (b)/(c) Geometry + highlight. The convergence adapter re-aims the
// virtualizer across several frames (scrollToIndex on an estimated index,
// then re-resolved once rows measure), and only applies the highlight on
// the settled frame via `onConverged`. The row therefore becomes DOM-
// visible (passing `toBeVisible` above) at an intermediate overshoot
// position one or more frames before it is centered and highlighted. We
// poll the row's placement until it satisfies the full settled contract --
// centered AND carrying the highlight class -- inside the 2s highlight-fade
// window. A single synchronous read here would race the convergence loop.
const placement = await timeline.evaluate((timelineEl, id) => {
const t = timelineEl as HTMLDivElement;
const row = t.querySelector<HTMLElement>(
`[data-message-id="${CSS.escape(id)}"]`,
);
if (!row) {
return null;
}
const tRect = t.getBoundingClientRect();
const rRect = row.getBoundingClientRect();
return {
rowTopRelative: rRect.top - tRect.top,
rowBottomRelative: rRect.bottom - tRect.top,
timelineHeight: tRect.height,
rowHeight: rRect.height,
className: row.className,
};
return new Promise<{
rowTopRelative: number;
rowBottomRelative: number;
timelineHeight: number;
rowHeight: number;
className: string;
} | null>((resolve) => {
const deadline = performance.now() + 3_000;
const tick = () => {
const row = t.querySelector<HTMLElement>(
`[data-message-id="${CSS.escape(id)}"]`,
);
if (row) {
const tRect = t.getBoundingClientRect();
const rRect = row.getBoundingClientRect();
const result = {
rowTopRelative: rRect.top - tRect.top,
rowBottomRelative: rRect.bottom - tRect.top,
timelineHeight: tRect.height,
rowHeight: rRect.height,
className: row.className,
};
const centered =
Math.abs(
(result.rowTopRelative + result.rowBottomRelative) / 2 -
result.timelineHeight / 2,
) <=
result.timelineHeight / 2;
if (
centered &&
result.className.includes("route-target-highlight-fade")
) {
resolve(result);
return;
}
}
if (performance.now() > deadline) {
resolve(
row
? {
rowTopRelative:
row.getBoundingClientRect().top -
t.getBoundingClientRect().top,
rowBottomRelative:
row.getBoundingClientRect().bottom -
t.getBoundingClientRect().top,
timelineHeight: t.getBoundingClientRect().height,
rowHeight: row.getBoundingClientRect().height,
className: row.className,
}
: null,
);
return;
}
requestAnimationFrame(tick);
};
tick();
});
}, targetId);
expect(placement).not.toBeNull();
const p = placement as NonNullable<typeof placement>;
@@ -676,9 +817,13 @@ test("find-bar active match scrolls and highlights row regardless of position",
} else if (i === bravoIndex) {
body = `filler message ${i}\n${bravo}`;
}
// Monotonic createdAt so ALPHA/BRAVO land at their true sorted
// positions (index 20 / 110) rather than random UUID-tiebreak slots;
// matches the channel-intro seed precedent. Needle assertions untouched.
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: body,
createdAt: 1_700_000_000 + i,
});
}
},
@@ -893,9 +1038,16 @@ test("composer expansion does not push bottom row out of viewport", async ({
({ total, bottom }) => {
for (let i = 0; i < total; i += 1) {
const body = i === total - 1 ? bottom : `filler message ${i}`;
// Monotonic createdAt: without it all rows share one whole-second
// stamp and `sortMessages` tiebreaks by random UUID, so BOTTOM_NEEDLE
// (seeded last) lands at a random sorted index — often outside the
// virtualized window, so `toContainText(BOTTOM_NEEDLE)` flakes. A
// distinct stamp per row sorts the needle to the true last row.
// Matches the channel-intro seed precedent (1_700_000_001 + index).
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: body,
createdAt: 1_700_000_000 + i,
});
}
},
@@ -1019,9 +1171,13 @@ test("in-viewport reflow above the anchor row does not push it down", async ({
// rows above whatever we anchor to.
await page.evaluate(() => {
for (let index = 0; index < 60; index += 1) {
// Monotonic createdAt so `resize-anchor row 59` (seeded last) sorts to
// the genuine last row rather than a random UUID-tiebreak position; see
// the composer seed for the full rationale.
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: `resize-anchor row ${index}\nsecond line ${index}\nthird line ${index}`,
createdAt: 1_700_000_000 + index,
});
}
});

View File

@@ -175,4 +175,134 @@ test.describe("list virtualization", () => {
// content-visibility invariant the divergence rests on (no unmount).
await expect(headers).toHaveCount(2);
});
test("07 — load-older prepend holds the anchored row without jitter or reconcile spin", async ({
page,
}) => {
// Install once: addInitScript re-runs on every navigation in this page, so
// each page.goto in the loop below re-applies the mock bridge.
await installMockBridge(page);
// The deep-history channel seeds 600 messages; the initial load windows to
// the newest 200, leaving 400 older behind the until cursor — enough that
// every run lands a genuine prepend. Reads the first row at/below the
// viewport top and returns scrollTop, scrollHeight, and that row's on-screen
// VIEWPORT position in ONE settled snapshot — the position the single-writer
// restore must hold steady across the prepend.
//
// Waits inside the browser for a measurement-settled frame before reading.
// The virtualizer re-windows after a scroll: for a few rAFs the mounted rows
// can all sit above the viewport top (their absolute offsets lag the new
// scrollTop) until the library mounts rows at the current position. That is
// a measurement transient, NOT the scrollTop race — scrollTop is already
// correct on those frames. Reading on such a frame would throw "no row";
// polling for a settled frame removes the flake without touching any
// race-detection threshold below (scrollTop value + viewportPos stability),
// and snapshots all three fields together so they can't skew across reads.
const sampleAnchor = (timeline: Locator) =>
timeline.evaluate(async (scroller) => {
const s = scroller as HTMLElement;
for (let frame = 0; frame < 60; frame += 1) {
const scrollerTop = s.getBoundingClientRect().top;
const row = Array.from(
s.querySelectorAll<HTMLElement>("[data-message-id]"),
).find((r) => r.getBoundingClientRect().top - scrollerTop >= 0);
if (row) {
return {
viewportPos: row.getBoundingClientRect().top - scrollerTop,
scrollTop: s.scrollTop,
scrollHeight: s.scrollHeight,
};
}
await new Promise((resolve) => requestAnimationFrame(resolve));
}
throw new Error("no anchor row mounted after 60 frames");
});
// Determinism is the bar, not pass-once. The original defect was a RACE: a
// second restore loop (the resize-observer restoring to the pre-fetch
// scrollTop of 0, fired by the load-older spinner's clientHeight shift)
// fought the anchor restore frame-by-frame; last writer won, so the anchor
// held only ~2 of 3 runs and on its losing runs scrollTop collapsed to ~0
// (view stuck at the top, anchor lost). A single prepend can go green on a
// lucky scheduling order, so this drives the prepend on SIX fresh page loads
// and asserts the anchor holds on every one — a flaky-pass fails the run.
// Fresh navigation each iteration resets the virtualizer's measurement state,
// matching the run-to-run conditions under which the race surfaced.
for (let run = 0; run < 6; run += 1) {
// Force a full document reload each iteration. Navigating straight to the
// same hash route is a same-document hash change, not a reload, so the
// virtualizer + paginated history would carry over and later runs would
// exhaust the older pages — defeating the per-run fresh-prepend premise.
await page.goto("about:blank");
await page.goto("/#/channels/feedf00d-0000-4000-8000-000000000007");
const timeline = page.getByTestId("message-timeline");
await expect(timeline).toBeVisible();
await expect(
page.locator('[data-message-id^="mock-deep-history-"]').first(),
).toBeVisible();
// Scroll up to mount mid-history rows while staying clear of the load-older
// sentinel zone (trips within 200px of the top), then let the windowed rows
// measure off their 80px estimate so the pre-prepend anchor reading is
// stable. The single trigger is the deliberate scrollTop = 0 below.
await timeline.evaluate((el) => {
el.scrollTop = 4000;
});
await page.waitForTimeout(300);
await timeline.evaluate((el) => {
el.scrollTop = 4000;
});
await page.waitForTimeout(150);
const before = await sampleAnchor(timeline);
expect(before.scrollTop).toBeGreaterThan(200);
// Trigger exactly one prepend. Scrolling to 150 trips the load-older
// sentinel (its rootMargin reaches 200px past the top) with
// previousScrollTopRef pinned near the top — the condition under which the
// resize-observer's competing restore collapsed the anchor pre-fix. After
// the single fetchOlder lands, the anchor restore carries scrollTop deep
// into the content, clear of the 200px sentinel zone, so the observer does
// NOT re-fire: one clean prepend, not the re-trigger storm that scrollTop
// 0 produces (0 keeps the sentinel tripped across every paged window down
// to the small exhaustion-tail page, which legitimately lands the top row
// near the top — masking the hold signal).
await timeline.evaluate((el) => {
el.scrollTop = 150;
});
// Anchor-hold gate (the race signal): poll until the restore has carried
// scrollTop deep into the content — past where it sat before the prepend.
// Pre-fix, the competing resize-observer restore (firing on the spinner's
// clientHeight shift, restoring to previousScrollTopRef ~150) won often
// enough that scrollTop stayed pinned near the top; this poll would then
// time out, failing the run. scrollHeight grows several frames BEFORE the
// restore moves scrollTop, so a scrollHeight gate would read mid-cycle
// near the top — the race lives in scrollTop, so the gate watches it.
await expect
.poll(async () => (await sampleAnchor(timeline)).scrollTop, {
timeout: 10_000,
})
.toBeGreaterThan(before.scrollTop);
// One settled snapshot for the remaining checks so scrollHeight and
// viewportPos come from the same frame as the held scrollTop:
// (a) the scroller grew by the prepended rows' height (genuine prepend),
// (b) the first-visible row sits where it did before the prepend.
const after = await sampleAnchor(timeline);
expect(after.scrollHeight).toBeGreaterThan(before.scrollHeight + 800);
expect(Math.abs(after.viewportPos - before.viewportPos)).toBeLessThan(
120,
);
// Reconcile terminates: two equal scrollTop reads 600ms apart prove the
// rAF loop stopped. Under the double-writer bug the library re-scheduled
// one rAF per frame for the full 5s MAX_RECONCILE_MS valve — still churning
// 600ms apart.
const settled1 = await timeline.evaluate((el) => el.scrollTop);
await page.waitForTimeout(600);
const settled2 = await timeline.evaluate((el) => el.scrollTop);
expect(Math.abs(settled1 - settled2)).toBeLessThan(2);
}
});
});