Rework desktop message-timeline scrolling: de-virtualize + native overflow-anchor (#1338)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-06-29 08:56:48 -06:00
committed by GitHub
parent c1d6f3f291
commit 4d6196934e
20 changed files with 876 additions and 1712 deletions

View File

@@ -54,6 +54,8 @@ export default defineConfig({
"**/reminders.spec.ts",
"**/virtualization.spec.ts",
"**/scroll-history.spec.ts",
"**/cold-switch-longtask.perf.ts",
"**/timeline-no-shift.spec.ts",
],
use: {
...devices["Desktop Chrome"],

View File

@@ -1,5 +1,6 @@
import * as React from "react";
import { useAppShell } from "@/app/AppShellContext";
import { cacheSearchHitEvent } from "@/app/navigation/searchHitEventCache";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useActiveChannelHeader } from "@/features/channels/useActiveChannelHeader";
import { useChannelPaneHandlers } from "@/features/channels/useChannelPaneHandlers";
@@ -50,7 +51,7 @@ import { useChannelTyping } from "@/features/messages/useChannelTyping";
import type { TimelineMessage } from "@/features/messages/types";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import { mergeCurrentProfileIntoLookup } from "@/features/profile/lib/identity";
import type { RespondToMode } from "@/shared/api/types";
import type { RelayEvent, RespondToMode, SearchHit } from "@/shared/api/types";
import { useChannelFind } from "@/features/search/useChannelFind";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
import { AgentSessionProvider } from "@/shared/context/AgentSessionContext";
@@ -242,13 +243,26 @@ export function ChannelScreen({
const deleteMessageMutation = useDeleteMessageMutation(activeChannel);
const editMessageMutation = useEditMessageMutation(activeChannel);
const joinChannelMutation = useJoinChannelMutation(activeChannelId);
const [findTargetEvents, setFindTargetEvents] = React.useState<RelayEvent[]>(
[],
);
// biome-ignore lint/correctness/useExhaustiveDependencies: clear spliced find results exactly when the active channel changes.
React.useEffect(() => {
setFindTargetEvents([]);
}, [activeChannelId]);
const resolvedMessages = React.useMemo(() => {
const currentMessages = messagesQuery.data ?? [];
if (!activeChannel || targetMessageEvents.length === 0) {
const extraEvents = [...targetMessageEvents, ...findTargetEvents];
if (!activeChannel || extraEvents.length === 0) {
return currentMessages;
}
return targetMessageEvents.reduce(mergeMessages, currentMessages);
}, [activeChannel, messagesQuery.data, targetMessageEvents]);
return extraEvents.reduce(mergeMessages, currentMessages);
}, [
activeChannel,
findTargetEvents,
messagesQuery.data,
targetMessageEvents,
]);
const messageAuthorPubkeys = React.useMemo(
() => collectMessageAuthorPubkeys(resolvedMessages),
[resolvedMessages],
@@ -417,9 +431,18 @@ export function ChannelScreen({
resolvedMessages,
],
);
const handleFindSearchHit = React.useCallback((hit: SearchHit) => {
const event = cacheSearchHitEvent(hit);
setFindTargetEvents((currentEvents) =>
currentEvents.some((currentEvent) => currentEvent.id === event.id)
? currentEvents
: [...currentEvents, event],
);
}, []);
const channelFind = useChannelFind({
channelId: activeChannelId,
messages: timelineMessages,
onSearchHit: handleFindSearchHit,
});
const {
firstUnreadMessageId,

View File

@@ -48,7 +48,7 @@ type MessageQueryContext = {
queryKey: ReturnType<typeof channelMessagesKey>;
};
const CHANNEL_HISTORY_LIMIT = 300;
const CHANNEL_HISTORY_LIMIT = 60;
function getLocalRenderKey(message: RelayEvent) {
return message.localKey ?? message.id;
@@ -304,7 +304,7 @@ export function useChannelSubscription(channel: Channel | null) {
cleanup = dispose;
// No post-subscribe history refetch: useChannelMessagesQuery already
// loaded the latest CHANNEL_HISTORY_LIMIT (300) events, and the live
// loaded the latest CHANNEL_HISTORY_LIMIT events, and the live
// subscription itself backfills up to 50 most-recent events via its
// initial REQ (buildChannelFilter(id, 50)). Both write into the same
// channelMessagesKey cache, so any window between the two REQs is

View File

@@ -22,23 +22,10 @@ export const MIN_TOP_LEVEL_ROWS_PER_FETCH = 30;
// Hard ceiling on relay pages fetched in one pass. On reply-heavy channels a
// batch yields only a few visible rows, so the row floor alone could dig through
// hundreds of messages behind one spinner and commit them at once (a sudden
// "flood" with a tiny scrollbar). Capping per-pass keeps each fetch a bounded
// page; the scroll observer re-arms to page further while in view.
// hundreds of messages behind one spinner. Capping per-pass keeps each fetch a
// bounded page; the scroll observer re-arms to page further while in view.
const MAX_BATCHES_PER_FETCH = 3;
// Yield a frame so the rows just merged paint before the next round-trip;
// otherwise a multi-batch pass renders as one bulk commit at the loop's end.
function yieldToPaint(): Promise<void> {
return new Promise((resolve) => {
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => resolve());
} else {
setTimeout(resolve, 0);
}
});
}
export type PageOlderResult = {
/** False once a short relay page proves history is exhausted. */
hasOlderMessages: boolean;
@@ -68,16 +55,15 @@ export async function pageOlderMessagesUntilRowFloor(
let hasOlderMessages = true;
let batchesFetched = 0;
while (hasOlderMessages && shouldContinue()) {
const before = queryClient.getQueryData<RelayEvent[]>(queryKey) ?? [];
if (before.length === 0) {
break;
}
// Accumulate every batch of this pass and commit to the cache once at the
// end. Committing per batch paints the timeline in several small steps under
// one spinner — on reply-heavy windows each 200-event batch adds only a few
// visible rows, so the user sees the loader dribble messages in 1-5 at a
// time. One commit = one bounded growth step.
const fetched: RelayEvent[] = [];
let oldestTimestamp = baseline[0].created_at;
// `until` is inclusive — the relay returns the boundary message again, but
// sortMessages dedupes by id. Subtracting 1 risks skipping same-second
// messages.
const oldestTimestamp = before[0].created_at;
while (hasOlderMessages && shouldContinue()) {
const olderMessages = await relayClient.fetchChannelHistoryBefore(
channelId,
oldestTimestamp,
@@ -97,25 +83,24 @@ export async function pageOlderMessagesUntilRowFloor(
}
if (olderMessages.length > 0) {
queryClient.setQueryData<RelayEvent[]>(queryKey, (current = []) =>
mergeTimelineHistoryMessages(current, olderMessages),
);
void backfillAuxForMessages(queryClient, channelId, olderMessages);
fetched.push(...olderMessages);
}
// Progress guard, not exhaustion: if the oldest timestamp didn't move back
// (empty page, or all-duplicate), stop this pass to avoid re-fetching the
// same `until`.
const oldestAfterMerge = (queryClient.getQueryData<RelayEvent[]>(
queryKey,
) ?? [])[0]?.created_at;
if (oldestAfterMerge === undefined || oldestAfterMerge >= oldestTimestamp) {
const oldestFetched = fetched.reduce(
(min, event) => (event.created_at < min ? event.created_at : min),
oldestTimestamp,
);
if (oldestFetched >= oldestTimestamp) {
break;
}
oldestTimestamp = oldestFetched;
const rowsGained =
countTopLevelTimelineRows(
queryClient.getQueryData<RelayEvent[]>(queryKey) ?? [],
mergeTimelineHistoryMessages(baseline, fetched),
) - baselineRowCount;
if (rowsGained >= MIN_TOP_LEVEL_ROWS_PER_FETCH) {
break;
@@ -124,8 +109,13 @@ export async function pageOlderMessagesUntilRowFloor(
if (batchesFetched >= MAX_BATCHES_PER_FETCH) {
break;
}
}
await yieldToPaint();
if (fetched.length > 0 && shouldContinue()) {
queryClient.setQueryData<RelayEvent[]>(queryKey, (current = []) =>
mergeTimelineHistoryMessages(current, fetched),
);
void backfillAuxForMessages(queryClient, channelId, fetched);
}
return { hasOlderMessages };

View File

@@ -1,210 +0,0 @@
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

@@ -1,151 +0,0 @@
/**
* 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

@@ -2,11 +2,7 @@ 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";
import { buildTimelineItems, getTimelineItemKey } from "./timelineItems.ts";
function dayAt(year, month, day, hour = 12) {
return Math.floor(
@@ -90,71 +86,9 @@ test("buildTimelineItems: system messages flatten to a 'system' item", () => {
assert.deepEqual(kinds(items), ["day-divider", "message", "system"]);
});
test("buildTimelineItems: empty entries produce no items and an empty map", () => {
const { items, indexByMessageId } = buildTimelineItems([], null);
test("buildTimelineItems: empty entries produce no items", () => {
const { items } = 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", () => {
@@ -166,66 +100,3 @@ test("getTimelineItemKey: keys are unique across the stream", () => {
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

@@ -1,13 +1,9 @@
/**
* 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`.
* discriminated-union item stream the list renders one row per entry.
*
* 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.
* suite.
*/
import {
@@ -32,8 +28,6 @@ export type TimelineItem =
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. */
@@ -41,30 +35,6 @@ 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;
}
@@ -72,16 +42,13 @@ function entryRenderKey(entry: MainTimelineEntry): string {
/**
* 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.
* message, then the message/system row itself.
*/
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
@@ -112,9 +79,8 @@ export function buildTimelineItems(
}
const kind = message.kind === KIND_SYSTEM_MESSAGE ? "system" : "message";
indexByMessageId.set(message.id, items.length);
items.push({ kind, key: renderKey, entry });
}
return { items, indexByMessageId };
return { items };
}

View File

@@ -1,26 +0,0 @@
/**
* 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

@@ -269,7 +269,7 @@ export function MessageThreadPanelSkeleton({
const threadBody = (
<div
className={cn(
"min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain pb-24 [overflow-anchor:none]",
"min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain pb-24",
isSplitLayout && auxiliaryPanelContentPaddingClass,
!isSplitLayout && !isFloatingOverlay && "pt-[3.25rem]",
)}
@@ -622,7 +622,7 @@ export function MessageThreadPanel({
const threadScrollRegion = (
<div
className={cn(
"min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain pb-24 [overflow-anchor:none]",
"min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain pb-24",
isSplitLayout && auxiliaryPanelContentPaddingClass,
!isSplitLayout && !isFloatingOverlay && "pt-[3.25rem]",
)}

View File

@@ -10,21 +10,17 @@ 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 = {
@@ -190,43 +186,6 @@ 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
@@ -251,33 +210,6 @@ 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,
@@ -305,20 +237,15 @@ const MessageTimelineBase = React.forwardRef<
isAtBottom,
newMessageCount,
onScroll,
restoreScrollPosition,
scrollToBottom,
scrollToBottomOnNextUpdate,
scrollToMessage,
setLoadOlderRestoreInFlight,
getAnchorIsAtBottom,
} = useAnchoredScroll({
channelId,
contentRef,
convergeToTarget,
isLoading: showTimelineSkeleton,
messages: deferredMessages,
onTargetReached,
pinToBottomByIndex,
scrollContainerRef,
targetMessageId,
});
@@ -350,65 +277,14 @@ 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),
});
// Jump-to-message is purely DOM-based now: all loaded rows are mounted, so
// `scrollToMessage` always finds the target row. No virtualizer convergence.
const jumpToMessage = React.useCallback(
(messageId: string, options?: { behavior?: ScrollBehavior }) => {
if (scrollToMessage(messageId, { highlight: true, ...options })) {
return;
}
if (virtualizerOption) {
convergeToMessage(messageId);
}
return scrollToMessage(messageId, { highlight: true, ...options });
},
[convergeToMessage, scrollToMessage, virtualizerOption],
[scrollToMessage],
);
// 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
@@ -448,8 +324,12 @@ const MessageTimelineBase = React.forwardRef<
// 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);
const pendingSearchTargetRef = React.useRef<string | null>(null);
React.useEffect(() => {
if (showTimelineSkeleton) return;
if (!searchActiveMessageId) {
pendingSearchTargetRef.current = null;
}
if (
!searchActiveMessageId ||
searchActiveMessageId === prevSearchActiveRef.current
@@ -457,20 +337,28 @@ const MessageTimelineBase = React.forwardRef<
prevSearchActiveRef.current = searchActiveMessageId;
return;
}
pendingSearchTargetRef.current = null;
prevSearchActiveRef.current = searchActiveMessageId;
jumpToMessage(searchActiveMessageId, { behavior: "smooth" });
if (!jumpToMessage(searchActiveMessageId, { behavior: "smooth" })) {
pendingSearchTargetRef.current = searchActiveMessageId;
}
}, [jumpToMessage, searchActiveMessageId, showTimelineSkeleton]);
// biome-ignore lint/correctness/useExhaustiveDependencies: deferredMessages is the intentional retry trigger — a search hit outside the initial window is spliced into messages asynchronously, and the DOM scroll should retry when that row commits.
React.useEffect(() => {
const target = pendingSearchTargetRef.current;
if (!target || showTimelineSkeleton) return;
if (jumpToMessage(target, { behavior: "auto" })) {
pendingSearchTargetRef.current = null;
}
}, [deferredMessages, jumpToMessage, showTimelineSkeleton]);
useLoadOlderOnScroll({
fetchOlder,
hasOlderMessages,
isLoading: showTimelineSkeleton,
restoreScrollPosition,
setLoadOlderRestoreInFlight,
getAnchorIsAtBottom,
scrollContainerRef,
sentinelRef: topSentinelRef,
virtualizer: virtualizerOption,
});
const timelineSkeletonRows = useTimelineSkeletonRows({
@@ -482,18 +370,6 @@ 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(
@@ -527,7 +403,7 @@ const MessageTimelineBase = React.forwardRef<
) : null}
<div
className={cn(
"absolute inset-0 overflow-y-auto overflow-x-hidden overscroll-none px-2 pt-1 [overflow-anchor:none]",
"absolute inset-0 overflow-y-auto overflow-x-hidden overscroll-none px-2 pt-1",
hasComposerOverlay ? "pb-24" : "pb-4",
)}
data-scroll-restoration-id={scrollRestorationId}
@@ -540,7 +416,8 @@ const MessageTimelineBase = React.forwardRef<
className={cn(
"flex w-full flex-col gap-2",
channelChrome.contentPadding,
(showIntro || showGenericEmpty) && "min-h-full",
(showIntro || showGenericEmpty || showMessageList) &&
"min-h-full",
)}
ref={contentRef}
>
@@ -730,9 +607,6 @@ const MessageTimelineBase = React.forwardRef<
searchQuery={searchQuery}
threadUnreadCounts={threadUnreadCounts}
unfollowThreadById={unfollowThreadById}
scrollContainerRef={scrollContainerRef}
headerOverlayRef={activeDayHeaderRef}
onVirtualizer={handleVirtualizer}
/>
</div>
) : null}

View File

@@ -4,7 +4,6 @@ import { formatDayHeading } from "@/features/messages/lib/dateFormatters";
import {
buildTimelineItems,
getTimelineItemKey,
resolveActiveDayTimestamp,
type TimelineItem,
} from "@/features/messages/lib/timelineItems";
import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel";
@@ -18,12 +17,7 @@ import type { TimelineMessage } from "@/features/messages/types";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { ChannelType } from "@/shared/api/types";
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";
@@ -78,13 +72,6 @@ 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;
};
export const TimelineMessageList = React.memo(function TimelineMessageList({
@@ -117,9 +104,6 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
searchQuery,
threadUnreadCounts,
unfollowThreadById,
scrollContainerRef,
headerOverlayRef,
onVirtualizer,
}: TimelineMessageListProps) {
const entries = React.useMemo(
() => mainEntries ?? buildMainTimelineEntries(messages),
@@ -171,11 +155,8 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
reviewCommentsByRootId,
]);
// 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.
// The flattened item stream, memoized on the entries and the unread boundary
// (the unread divider is its own item, so it shifts subsequent rows).
const itemsResult = React.useMemo(
() => buildTimelineItems(entries, firstUnreadMessageId),
[entries, firstUnreadMessageId],
@@ -260,36 +241,14 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
],
);
// 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}
/>
<div className="flex flex-col">
{itemsResult.items.map((item) => (
<div className="timeline-row-cv" key={getTimelineItemKey(item)}>
{renderItem(item)}
</div>
))}
</div>
);
});

View File

@@ -31,24 +31,6 @@ type UseAnchoredScrollOptions = {
/** 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 = {
@@ -72,20 +54,6 @@ 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) {
@@ -138,104 +106,6 @@ function computeAnchor(container: HTMLDivElement): AnchorState {
return { kind: "at-bottom" };
}
/**
* Find the rendered message id that is closest in chronological order to
* the anchor, scanning forward in `messages`. Used as the fallback when the
* anchor's row is gone post-render (e.g. message deleted).
*/
function findNearestNewerMessageId(
container: HTMLDivElement,
messages: TimelineMessage[],
anchorId: string,
): string | null {
const anchorIndex = messages.findIndex((m) => m.id === anchorId);
if (anchorIndex < 0) return null;
for (let i = anchorIndex + 1; i < messages.length; i++) {
const candidate = messages[i];
const el = container.querySelector(`[data-message-id="${candidate.id}"]`);
if (el) return candidate.id;
}
return null;
}
/**
* Restore a message-kind anchor's on-screen offset after a layout shift.
*
* Finds the anchor row (or the nearest newer rendered row if the anchor
* itself was removed), measures its current top-relative offset, and
* `scrollBy(0, delta)` if the offset has drifted. Returns the new anchor
* state the caller should write back:
* - `{ kind: "message", ... }` — anchor (or its fallback) is in the DOM
* and now sits at its previous offset.
* - `{ kind: "at-bottom" }` — anchor and all newer rendered rows are gone;
* caller should pin to the bottom and update at-bottom state.
*
* `scrollBy` is intentional over `scrollTop = ...`: relative adjustment
* composes with the browser's own scroll anchoring and doesn't fight a
* smooth-scroll in flight. Same rationale as the layout-effect restore.
*
* Used by both the post-commit layout effect (prepend / append / spinner
* toggle / etc.) and the ResizeObserver (in-viewport reflow from image
* decode, embed expansion, font load). Keeping them on one primitive
* preserves the single-owner invariant of the hook.
*/
function restoreAnchorToMessage(
container: HTMLDivElement,
messages: TimelineMessage[],
anchor: Extract<AnchorState, { kind: "message" }>,
): AnchorState {
let anchorEl = container.querySelector<HTMLElement>(
`[data-message-id="${anchor.messageId}"]`,
);
let usedAnchor: AnchorState = anchor;
if (!anchorEl) {
const fallbackId = findNearestNewerMessageId(
container,
messages,
anchor.messageId,
);
if (fallbackId) {
anchorEl = container.querySelector<HTMLElement>(
`[data-message-id="${fallbackId}"]`,
);
if (anchorEl) {
usedAnchor = {
kind: "message",
messageId: fallbackId,
topOffset: anchor.topOffset,
};
}
}
}
if (!anchorEl) {
// 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" };
}
const containerTop = container.getBoundingClientRect().top;
const currentTop = anchorEl.getBoundingClientRect().top - containerTop;
const delta = currentTop - usedAnchor.topOffset;
if (Math.abs(delta) > 0.5) {
container.scrollBy(0, delta);
}
return usedAnchor;
}
export function useAnchoredScroll({
scrollContainerRef,
contentRef,
@@ -245,18 +115,11 @@ export function useAnchoredScroll({
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
// restoration). useState would force re-renders we don't want.
const anchorRef = React.useRef<AnchorState>({ kind: "at-bottom" });
// Latest `messages` mirrored to a ref so the ResizeObserver effect can read
// the current list without re-subscribing the observer on every commit
// (which would also drop any in-flight resize callbacks). Kept fresh by a
// layout effect below so the read is consistent with what's in the DOM.
const messagesRef = React.useRef<TimelineMessage[]>(messages);
const [isAtBottom, setIsAtBottom] = React.useState(true);
const [newMessageCount, setNewMessageCount] = React.useState(0);
const [highlightedMessageId, setHighlightedMessageId] = React.useState<
@@ -264,39 +127,10 @@ 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 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).
@@ -306,12 +140,6 @@ export function useAnchoredScroll({
// 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
@@ -327,10 +155,7 @@ export function useAnchoredScroll({
prevFirstMessageIdRef.current = undefined;
prevMessageCountRef.current = 0;
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;
@@ -346,17 +171,6 @@ 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);
@@ -398,13 +212,12 @@ export function useAnchoredScroll({
const targetTopOffset =
currentTopOffset - (targetScrollTop - container.scrollTop);
el.scrollIntoView({
block: "center",
container.scrollTo({
top: targetScrollTop,
behavior: options.behavior ?? "auto",
});
// `scrollIntoView({ behavior: "smooth" })` starts an async animation, so
// measuring after the call can still return the pre-animation position.
// Smooth scrolling starts an async animation, so measuring after the call can still return the pre-animation position.
// Save the clamped destination offset instead; otherwise a concurrent
// render/ResizeObserver restore can fight the smooth scroll back toward
// where it started.
@@ -432,77 +245,12 @@ 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));
@@ -512,51 +260,27 @@ export function useAnchoredScroll({
}, [scrollContainerRef]);
// ---------------------------------------------------------------------------
// Anchor restoration: after every render, if the anchor was a message,
// realign so that message sits at the same top-relative offset it had
// before the render. This 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.
// Anchor restoration: after every render, stick to the bottom if the user is
// there. The reading position across prepend / in-viewport reflow is held by
// the browser's native scroll anchoring (overflow-anchor) now that every
// loaded row stays in the DOM, so there is no JS message-anchor restore.
// ---------------------------------------------------------------------------
React.useLayoutEffect(() => {
const container = scrollContainerRef.current;
if (!container) return;
// Mirror the current messages list into the ref read by the
// ResizeObserver's restore path. Must happen before any early return so
// a non-React layout shift sees the same array the next restoration
// would use.
messagesRef.current = messages;
// First render after a reset (channel switch or initial mount): jump
// to the requested target message, or to the bottom by default.
if (!hasInitializedRef.current) {
if (isLoading) return;
// 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.
// Defer the scroll out of the layout effect so the current paint commits
// first; cancelled on channel switch via the reset effect's rAF guard.
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");
}
scrollToBottomImperative("auto");
});
};
if (targetMessageId) {
@@ -567,11 +291,6 @@ export function useAnchoredScroll({
// `messages`) retry once the row lands, rather than marking it handled.
if (scrollToMessageImperative(targetMessageId, { highlight: true })) {
handledTargetIdRef.current = targetMessageId;
// Consumers clear the route target (`messageId` URL param) on this
// callback. The post-mount target effect below also fires it, but
// for a target already in the DOM on first commit that effect bails
// (handled ref is set), so the initial path must fire it too — else
// the param sticks and re-clicking the same deep link is a no-op.
onTargetReached?.(targetMessageId);
} else {
pinToBottomOnMount();
@@ -580,10 +299,6 @@ export function useAnchoredScroll({
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;
@@ -603,36 +318,11 @@ 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 =
const frontChanged =
firstMessage !== undefined &&
prevFirstId !== undefined &&
firstMessage.id !== prevFirstId &&
!newLatestArrived;
if (isPrepend) {
prevLastMessageIdRef.current = lastMessage?.id;
prevFirstMessageIdRef.current = firstMessage.id;
prevMessageCountRef.current = messages.length;
return;
}
firstMessage.id !== prevFirstId;
const isPrepend = frontChanged && !newLatestArrived;
// One-shot: an outbound send armed `scrollToBottomOnNextUpdate`. When the
// resulting append lands, snap to bottom regardless of the current anchor,
@@ -651,21 +341,32 @@ export function useAnchoredScroll({
}
if (anchor.kind === "at-bottom") {
// Stick to bottom. Use scrollTo to avoid relying on scroll anchoring.
// Stick to bottom across the append.
container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
if (newLatestArrived) setNewMessageCount(0);
} else {
// Anchored to a specific message. The shared helper finds it (with a
// nearest-newer fallback if the row was removed) and corrects the
// offset via `scrollBy`. If both the anchor and all newer rendered
// rows are gone, it pins to the bottom and returns `at-bottom`.
const restored = restoreAnchorToMessage(container, messages, anchor);
anchorRef.current = restored;
if (restored.kind === "at-bottom") {
setIsAtBottom(true);
} else if (messagesArrived > 0) {
// Anchored mid-history. An older-history prepend grows the content above
// the reading row; the browser's native scroll anchoring does NOT correct
// this at the top edge (no anchor node above the viewport when scrollTop
// is ~0), so re-pin the anchored row to its saved offset by id. This is
// the single scroll writer for the prepend — the load-older observer only
// triggers the fetch. We run it in this post-commit layout effect (not the
// observer's promise callback) because the prepended rows commit on a
// deferred snapshot a few frames later, so the row's true position is only
// known here.
const row = container.querySelector<HTMLElement>(
`[data-message-id="${CSS.escape(anchor.messageId)}"]`,
);
if (row) {
const currentTopOffset =
row.getBoundingClientRect().top -
container.getBoundingClientRect().top;
const drift = currentTopOffset - anchor.topOffset;
if (Math.abs(drift) > 0.5) {
container.scrollBy(0, drift);
}
}
if (messagesArrived > 0) {
if (!isPrepend) {
setNewMessageCount((current) => current + messagesArrived);
}
}
@@ -684,16 +385,12 @@ export function useAnchoredScroll({
]);
// ---------------------------------------------------------------------------
// Content resize: when fonts load late, an image decodes, an embed expands,
// or any in-viewport reflow happens that React isn't driving (so the
// layout-effect doesn't fire), the anchor row's on-screen offset drifts.
//
// When stuck-to-bottom we re-pin to bottom. When anchored to a message we
// call the same restore primitive the layout effect uses, so an in-viewport
// reflow above the reader's eye shifts back into place. Without this,
// anything that resizes without changing `messages` (link-card decode,
// async embed expand, late font load, markdown that expands) silently
// pushes the reading row around.
// Content resize: while stuck to the bottom, an in-viewport reflow (image
// decode, embed expand, late font load) that React isn't driving grows
// `scrollHeight` without a `messages` change, so the layout effect doesn't
// fire — re-pin to the new floor here to stay glued. When anchored
// mid-history, native scroll anchoring (overflow-anchor) holds the reading
// row across the reflow, so there's nothing to do.
// ---------------------------------------------------------------------------
// 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(() => {
@@ -702,41 +399,8 @@ export function useAnchoredScroll({
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.
if (anchorRef.current.kind === "at-bottom") {
container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
return;
}
// Use the same restore primitive as the layout effect so the
// single-owner model holds across non-React-driven layout shifts.
const restored = restoreAnchorToMessage(
container,
messagesRef.current,
anchor,
);
anchorRef.current = restored;
if (restored.kind === "at-bottom") {
setIsAtBottom(true);
}
});
observer.observe(content);
@@ -759,7 +423,6 @@ export function useAnchoredScroll({
React.useEffect(() => {
if (!targetMessageId) {
handledTargetIdRef.current = null;
convergingTargetIdRef.current = null;
return;
}
if (handledTargetIdRef.current === targetMessageId || isLoading) return;
@@ -771,20 +434,9 @@ export function useAnchoredScroll({
`[data-message-id="${targetMessageId}"]`,
);
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;
}
// Row not in the DOM yet. A cold deep-link target is fetched by id and
// spliced into `messages` a render or two later; this effect re-runs on
// each `messages` commit and retries until the row exists.
return;
}
handledTargetIdRef.current = targetMessageId;
@@ -815,8 +467,5 @@ export function useAnchoredScroll({
scrollToBottom: scrollToBottomImperative,
scrollToBottomOnNextUpdate,
scrollToMessage: scrollToMessageImperative,
restoreScrollPosition,
setLoadOlderRestoreInFlight,
getAnchorIsAtBottom,
};
}

View File

@@ -1,192 +0,0 @@
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

@@ -1,120 +1,41 @@
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;
}
// How far above the viewport top the sentinel triggers a fetch. Larger = the
// next page preloads sooner as you scroll up. Kept comfortably below a typical
// prepend height (one fetch lands >=30 rows) so the landed page still pushes
// the sentinel out of the band and re-arms the once-per-gesture gate; a band
// taller than the prepend would leave the sentinel inside it and stall paging.
const PRELOAD_MARGIN_PX = 600;
/**
* 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.
* container enters the viewport.
*
* A single long-lived observer drives this: it fires `fetchOlder` once when the
* sentinel enters the trigger band, then *disarms* and will not fire again until
* the sentinel has left the band and re-entered it — i.e. a fresh scroll-up
* gesture. Re-arming with a new observer the instant the fetch resolved (the
* previous approach) fired a second fetch immediately: the prepended rows commit
* on a deferred snapshot a few frames later, so the sentinel is still inside the
* band when the new observer first reports it, cascading page-after-page off one
* gesture. Gating on the leave→enter transition needs no scroll-position
* coupling and pages exactly once per gesture.
*/
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;
@@ -129,184 +50,34 @@ export function useLoadOlderOnScroll({
}
let disposed = false;
let currentObserver: IntersectionObserver | null = null;
// Armed when the sentinel is outside the band; a single entry into the band
// fires one fetch and disarms until the sentinel leaves again.
let armed = true;
const observe = () => {
if (disposed) {
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (disposed) {
return;
}
if (!entry.isIntersecting) {
// Sentinel left the band (the prepend pushed it down, or the user
// scrolled away). Re-arm so the next scroll-up pages once more.
armed = true;
return;
}
if (!armed) {
return;
}
armed = false;
void fetchOlder();
},
{ root: container, rootMargin: `${PRELOAD_MARGIN_PX}px 0px 0px 0px` },
);
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();
observer.observe(sentinel);
return () => {
disposed = true;
currentObserver?.disconnect();
observer.disconnect();
};
}, [
fetchOlder,

View File

@@ -2,6 +2,7 @@ import * as React from "react";
import { useSearchMessagesQuery } from "@/features/search/hooks";
import type { TimelineMessage } from "@/features/messages/types";
import type { SearchHit } from "@/shared/api/types";
import { hasPrimaryShortcutModifier } from "@/shared/lib/platform";
const MIN_QUERY_LENGTH = 2;
@@ -10,9 +11,14 @@ const DEBOUNCE_MS = 300;
type UseChannelFindOptions = {
channelId: string | null;
messages: TimelineMessage[];
onSearchHit?: (hit: SearchHit) => void;
};
export function useChannelFind({ channelId, messages }: UseChannelFindOptions) {
export function useChannelFind({
channelId,
messages,
onSearchHit,
}: UseChannelFindOptions) {
const [isOpen, setIsOpen] = React.useState(false);
const [query, setQuery] = React.useState("");
const [debouncedQuery, setDebouncedQuery] = React.useState("");
@@ -64,22 +70,17 @@ export function useChannelFind({ channelId, messages }: UseChannelFindOptions) {
limit: 100,
});
// Merge: start with client-side matches, then supplement with relay hits
// that are loaded in the timeline but were missed by exact substring match
// (e.g. Typesense stemming). Only loaded messages are kept so the match
// count stays accurate relative to what's visible on screen.
const loadedMessageIds = React.useMemo(
() => new Set(messages.map((m) => m.id)),
[messages],
);
// Merge: start with client-side matches, then supplement with relay hits.
// Relay hits may refer to older messages outside the initial cold window;
// keep them in the match list and ask the route-target splice path to load
// the active hit so the DOM-based timeline scroll can land it.
const matchedIds = React.useMemo<string[]>(() => {
const merged = [...clientMatchIds];
const seen = new Set(merged);
if (relaySearch.data?.hits) {
for (const hit of relaySearch.data.hits) {
if (!seen.has(hit.eventId) && loadedMessageIds.has(hit.eventId)) {
if (!seen.has(hit.eventId)) {
merged.push(hit.eventId);
seen.add(hit.eventId);
}
@@ -87,7 +88,7 @@ export function useChannelFind({ channelId, messages }: UseChannelFindOptions) {
}
return merged;
}, [clientMatchIds, relaySearch.data?.hits, loadedMessageIds]);
}, [clientMatchIds, relaySearch.data?.hits]);
// Clamp active index when results change.
React.useEffect(() => {
@@ -100,6 +101,20 @@ export function useChannelFind({ channelId, messages }: UseChannelFindOptions) {
const activeMatch =
matchedIds.length > 0 ? { messageId: matchedIds[activeIndex] } : null;
const relayHitById = React.useMemo(() => {
const hits = new Map<string, SearchHit>();
for (const hit of relaySearch.data?.hits ?? []) {
hits.set(hit.eventId, hit);
}
return hits;
}, [relaySearch.data?.hits]);
React.useEffect(() => {
if (!activeMatch) return;
const hit = relayHitById.get(activeMatch.messageId);
if (hit) onSearchHit?.(hit);
}, [activeMatch, onSearchHit, relayHitById]);
const matchingMessageIds = React.useMemo(() => {
return new Set(matchedIds);
}, [matchedIds]);

View File

@@ -2047,6 +2047,13 @@
contain-intrinsic-size: auto 200px;
}
/* Timeline rows re-pin by measured offset on prepend; the 200px fallback
over-reserves the common row and shifts that anchor, so reserve low. */
.timeline-row-cv {
content-visibility: auto;
contain-intrinsic-size: auto 60px;
}
.content-visibility-auto-interactive {
content-visibility: auto;
contain-intrinsic-size: auto 200px;

View File

@@ -2522,6 +2522,11 @@ function emitMockHistory(
const delayMs = getConfig()?.mock?.historyDelayMs ?? 0;
const isVisibleOlderHistoryPage =
subId.startsWith("history-") && filter.until !== undefined && !filter["#e"];
if (isVisibleOlderHistoryPage) {
const counter = window as unknown as { __HISTORY_FETCH_COUNT__?: number };
counter.__HISTORY_FETCH_COUNT__ =
(counter.__HISTORY_FETCH_COUNT__ ?? 0) + 1;
}
if (delayMs > 0 && isVisibleOlderHistoryPage) {
const probe = window as unknown as {
__HISTORY_INFLIGHT__?: number;

View File

@@ -61,30 +61,6 @@ 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,
}) => {
@@ -243,15 +219,6 @@ test("preserves user scroll while older channel history loads", async ({
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
@@ -280,15 +247,6 @@ test("preserves user scroll while older channel history loads", async ({
},
)
.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.
@@ -1620,3 +1578,227 @@ test("older-history spinner stays visible in viewport while fetching mid-scroll"
});
expect(withinViewport).toBe(true);
});
// Regression for Wes's "scroll to the top once and it loads the WHOLE channel":
// a single scroll-up gesture must page older history ONCE (a bounded step), not
// cascade page-after-page to the channel start with no further user scroll. The
// cascade came from the load-older observer disconnecting and recreating itself
// after each fetch — the fresh observer re-reported the still-intersecting
// sentinel and re-fired immediately. A persistent observer only re-fires when
// the sentinel re-enters the top band, i.e. on a NEW scroll gesture.
//
// Distinct from the "never overlap" test (which asserts no CONCURRENT in-flight
// fetches): a cascade keeps peak in-flight at 1 while firing many SEQUENTIAL
// pages. This asserts the cumulative fetch count stays bounded after one
// gesture, and the timeline does not dig to the oldest seeded root on its own.
test("one scroll-up gesture pages older history once, not to the channel top", 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",
);
// Deep seed: a current window plus ~1200 older roots, far more than a single
// older page yields. If pagination auto-cascades, it will dig all the way to
// "mock older 0"; if it pages once per gesture, it stops well short.
await page.evaluate(() => {
for (let index = 0; index < 60; index += 1) {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: `recent ${index}`,
});
}
window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__?.({
channelName: "general",
count: 1200,
lineCount: 2,
});
(
window as unknown as { __HISTORY_FETCH_COUNT__?: number }
).__HISTORY_FETCH_COUNT__ = 0;
});
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const timeline = page.getByTestId("message-timeline");
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
await page.waitForFunction(() => {
const element = document.querySelector(
'[data-testid="message-timeline"]',
) as HTMLDivElement | null;
return element ? element.scrollHeight > element.clientHeight + 1000 : false;
});
// The cold load may itself page once to fill the row floor; ignore anything
// before the user gesture by resetting the counter at the settled bottom.
await page.evaluate(() => {
(
window as unknown as { __HISTORY_FETCH_COUNT__?: number }
).__HISTORY_FETCH_COUNT__ = 0;
});
const fetchCount = () =>
page.evaluate(
() =>
(window as unknown as { __HISTORY_FETCH_COUNT__?: number })
.__HISTORY_FETCH_COUNT__ ?? 0,
);
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(/mock older (\d+)/);
if (match) min = Math.min(min, Number(match[1]));
}
return Number.isFinite(min) ? min : null;
});
// ONE scroll-up gesture to the top band, then HANDS OFF. A single wheel tick
// is enough to bring the sentinel into the 200px rootMargin.
await timeline.hover();
await page.mouse.wheel(0, -4000);
// Let any cascade run unimpeded for a generous window. With the bug, the
// observer re-arms after each page and digs through all ~1200 older roots in
// this window; with the fix it pages once and waits for the next gesture.
await page.waitForTimeout(2_500);
const pagesFetched = await fetchCount();
const deepest = await oldestRenderedIndex();
// One gesture should yield a small, bounded number of pages — not dozens.
// pageOlderMessagesUntilRowFloor may fetch up to MAX_BATCHES_PER_FETCH (3)
// relay pages to satisfy one visible row floor, so allow that ceiling plus a
// little slack; a cascade blows far past it.
expect(pagesFetched).toBeLessThanOrEqual(4);
// And it must NOT have reached the oldest seeded root on its own.
expect(deepest ?? Number.POSITIVE_INFINITY).toBeGreaterThan(50);
});
// Regression for Wes's "after a page loads I'm yanked to the oldest of the new
// page instead of staying where I was reading" (event 6, timeline-scroll-rework
// thread). De-virtualizing the list re-enabled native `overflow-anchor`, but the
// browser does NOT scroll-anchor an older-history prepend at the top edge (no
// anchor node above the viewport at scrollTop ~0). The reading row must stay
// fixed across the prepend; the JS anchor restore in useAnchoredScroll owns it.
test("older-history prepend keeps the reading row fixed (no jump to oldest)", 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",
);
await page.evaluate(() => {
for (let index = 0; index < 40; index += 1) {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: `recent ${index}`,
});
}
window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__?.({
channelName: "general",
count: 1200,
lineCount: 2,
});
});
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const timeline = page.getByTestId("message-timeline");
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
await page.waitForFunction(() => {
const element = document.querySelector(
'[data-testid="message-timeline"]',
) as HTMLDivElement | null;
return element ? element.scrollHeight > element.clientHeight + 1000 : false;
});
// Pace the older fetch BEFORE scrolling up, so the very first older page the
// top sentinel triggers stays in flight long enough to read the anchor before
// and after it lands. No climb loop — the cold load left ~900 older roots
// behind the cursor, so one scroll-up to the top band fires a genuine page.
await page.evaluate(() => {
window.__BUZZ_E2E__ = {
...window.__BUZZ_E2E__,
mock: { ...window.__BUZZ_E2E__?.mock, historyDelayMs: 1_500 },
};
(
window as unknown as { __HISTORY_INFLIGHT__?: number }
).__HISTORY_INFLIGHT__ = 0;
});
const inflightCount = () =>
page.evaluate(
() =>
(window as unknown as { __HISTORY_INFLIGHT__?: number })
.__HISTORY_INFLIGHT__ ?? 0,
);
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(/mock older (\d+)/);
if (match) min = Math.min(min, Number(match[1]));
}
return Number.isFinite(min) ? min : null;
});
const oldestBeforeLanding = await oldestRenderedIndex();
expect(oldestBeforeLanding).not.toBeNull();
// One scroll-up gesture to the top band fires the (delayed) older page.
await timeline.hover();
for (let attempt = 0; attempt < 40; attempt += 1) {
if ((await inflightCount()) > 0) break;
await page.mouse.wheel(0, -3000);
await page.waitForTimeout(50);
}
expect(await inflightCount()).toBeGreaterThan(0);
// Capture the reading row WHILE the page is still in flight (prepend lands
// ~1.5s later). This is the row the restore must hold.
const anchorBeforeLanding = await getFirstVisibleMessage(page);
expect(anchorBeforeLanding).not.toBeNull();
// Poll until a genuinely older page has landed (inflight back to 0 AND a new
// older index appeared), then assert the anchored row barely moved. With the
// bug, the prepend lands above the row and shoves it down by the prepend
// height (hundreds of px); with the fix it holds within a couple px.
await expect
.poll(
async () => {
const [inflight, oldestNow, anchor] = await Promise.all([
inflightCount(),
oldestRenderedIndex(),
getMessagePosition(page, anchorBeforeLanding?.id ?? ""),
]);
const landed =
inflight === 0 &&
oldestNow !== null &&
oldestNow < (oldestBeforeLanding ?? 0);
if (!landed || !anchor) {
return Number.POSITIVE_INFINITY;
}
return Math.abs(anchor.top - (anchorBeforeLanding?.top ?? 0));
},
{ timeout: 4_000 },
)
.toBeLessThanOrEqual(2);
await expect(page.getByTestId("message-scroll-to-latest")).toContainText(
"Jump to latest",
);
});

View File

@@ -0,0 +1,429 @@
import { expect, test } from "@playwright/test";
import type { Locator, Page } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
type AnchorSnapshot = {
anchorId: string;
anchorTop: number;
visibleIds: string[];
rowsFromAnchor: string[];
oldestOlderIndex: number | null;
scrollHeight: number;
};
async function waitForMockTimelineBridge(page: Page) {
await page.waitForFunction(
() =>
typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" &&
typeof window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__ === "function",
);
}
async function seedNoShiftTimeline(page: Page) {
await page.evaluate(() => {
for (let index = 0; index < 40; index += 1) {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: `no-shift current ${index}\nsecond line ${index}\nthird line ${index}`,
createdAt: 1_700_100_000 + index,
});
}
window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__?.({
channelName: "general",
count: 600,
lineCount: 3,
createdAtStart: 1_700_000_000,
});
});
}
async function snapshotAnchor(timeline: Locator): Promise<AnchorSnapshot> {
return timeline.evaluate((element) => {
const scroller = element as HTMLDivElement;
const scrollerRect = scroller.getBoundingClientRect();
const rows = Array.from(
scroller.querySelectorAll<HTMLElement>("[data-message-id]"),
);
const visibleRows = rows.filter((row) => {
const rect = row.getBoundingClientRect();
return rect.bottom > scrollerRect.top && rect.top < scrollerRect.bottom;
});
const anchor =
visibleRows.find((row) => {
const rect = row.getBoundingClientRect();
return rect.top - scrollerRect.top >= 0;
}) ?? visibleRows[0];
if (!anchor) {
throw new Error("no visible message row to anchor");
}
const anchorIndex = rows.indexOf(anchor);
const oldestOlderIndex = rows.reduce<number | null>((oldest, row) => {
const match = row.textContent?.match(/mock older (\d+) line/);
if (!match) return oldest;
const index = Number(match[1]);
return oldest === null ? index : Math.min(oldest, index);
}, null);
return {
anchorId: anchor.dataset.messageId ?? "",
anchorTop: anchor.getBoundingClientRect().top - scrollerRect.top,
visibleIds: visibleRows
.map((row) => row.dataset.messageId ?? "")
.filter(Boolean),
rowsFromAnchor: rows
.slice(anchorIndex, anchorIndex + 5)
.map((row) => row.dataset.messageId ?? "")
.filter(Boolean),
oldestOlderIndex,
scrollHeight: scroller.scrollHeight,
};
});
}
async function getAnchorTop(
timeline: Locator,
anchorId: string,
): Promise<number | null> {
return timeline.evaluate((element, id) => {
const scroller = element as HTMLDivElement;
const anchor = scroller.querySelector<HTMLElement>(
`[data-message-id="${CSS.escape(id)}"]`,
);
if (!anchor) return null;
return (
anchor.getBoundingClientRect().top - scroller.getBoundingClientRect().top
);
}, anchorId);
}
async function startAnchorDriftSampler(
timeline: Locator,
anchorId: string,
baselineTop: number,
) {
await timeline.evaluate(
(element, input) => {
const scroller = element as HTMLDivElement;
const win = window as typeof window & {
__TIMELINE_NO_SHIFT_PROBE__?: {
stop: boolean;
maxDrift: number;
missingSamples: number;
samples: number;
};
};
const probe = {
stop: false,
maxDrift: 0,
missingSamples: 0,
samples: 0,
};
win.__TIMELINE_NO_SHIFT_PROBE__ = probe;
const sample = () => {
if (probe.stop) return;
const anchor = scroller.querySelector<HTMLElement>(
`[data-message-id="${CSS.escape(input.anchorId)}"]`,
);
if (!anchor) {
probe.missingSamples += 1;
} else {
const top =
anchor.getBoundingClientRect().top -
scroller.getBoundingClientRect().top;
probe.maxDrift = Math.max(
probe.maxDrift,
Math.abs(top - input.baselineTop),
);
}
probe.samples += 1;
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
},
{ anchorId, baselineTop },
);
}
async function stopAnchorDriftSampler(timeline: Locator) {
return timeline.evaluate((element) => {
const win = window as typeof window & {
__TIMELINE_NO_SHIFT_PROBE__?: {
stop: boolean;
maxDrift: number;
missingSamples: number;
samples: number;
};
};
const probe = win.__TIMELINE_NO_SHIFT_PROBE__;
if (!probe) throw new Error("no timeline no-shift sampler installed");
probe.stop = true;
return {
maxDrift: probe.maxDrift,
missingSamples: probe.missingSamples,
samples: probe.samples,
scrollTop: (element as HTMLDivElement).scrollTop,
};
});
}
async function growRowAboveAnchor(timeline: Locator, anchorId: string) {
return timeline.evaluate((element, id) => {
const scroller = element as HTMLDivElement;
const rows = Array.from(
scroller.querySelectorAll<HTMLElement>("[data-message-id]"),
);
const anchorIndex = rows.findIndex((row) => row.dataset.messageId === id);
if (anchorIndex <= 0) return false;
const target = rows[Math.max(0, anchorIndex - 2)];
if (!target) return false;
const currentHeight = target.getBoundingClientRect().height;
target.style.minHeight = `${currentHeight + 96}px`;
target.dataset.noShiftReflowTarget = "true";
return true;
}, anchorId);
}
function expectAnchorOrderUnchanged(
before: AnchorSnapshot,
after: AnchorSnapshot,
) {
expect(after.visibleIds).toContain(before.anchorId);
expect(after.rowsFromAnchor).toEqual(before.rowsFromAnchor);
}
test("timeline prepend plus late row reflow keeps the reading row stable", async ({
page,
}, testInfo) => {
testInfo.setTimeout(45_000);
await installMockBridge(page);
await page.goto("/");
await waitForMockTimelineBridge(page);
await seedNoShiftTimeline(page);
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const timeline = page.getByTestId("message-timeline");
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
await page.waitForFunction(() => {
const element = document.querySelector(
'[data-testid="message-timeline"]',
) as HTMLDivElement | null;
return element && element.scrollHeight > element.clientHeight + 1_000;
});
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;
});
await timeline.evaluate((element) => {
const scroller = element as HTMLDivElement;
scroller.scrollTop = 150;
scroller.dispatchEvent(new Event("scroll", { bubbles: true }));
});
await expect
.poll(
async () =>
page.evaluate(
() =>
(window as unknown as { __HISTORY_INFLIGHT__?: number })
.__HISTORY_INFLIGHT__ ?? 0,
),
{ timeout: 5_000 },
)
.toBeGreaterThan(0);
const before = await snapshotAnchor(timeline);
expect(before.anchorId).not.toBe("");
expect(before.oldestOlderIndex).not.toBeNull();
await startAnchorDriftSampler(timeline, before.anchorId, before.anchorTop);
await expect
.poll(
async () => {
const snapshot = await snapshotAnchor(timeline);
return snapshot.oldestOlderIndex !== null &&
before.oldestOlderIndex !== null &&
snapshot.oldestOlderIndex < before.oldestOlderIndex &&
snapshot.scrollHeight > before.scrollHeight + 1_000
? "landed"
: "pending";
},
{ timeout: 6_000 },
)
.toBe("landed");
const afterPrepend = await snapshotAnchor(timeline);
expect(afterPrepend.anchorId).toBe(before.anchorId);
expect(
Math.abs(afterPrepend.anchorTop - before.anchorTop),
).toBeLessThanOrEqual(2);
expectAnchorOrderUnchanged(before, afterPrepend);
const grew = await growRowAboveAnchor(timeline, before.anchorId);
expect(grew).toBe(true);
await expect
.poll(
async () => {
const top = await getAnchorTop(timeline, before.anchorId);
return top === null
? Number.POSITIVE_INFINITY
: Math.abs(top - before.anchorTop);
},
{ timeout: 3_000 },
)
.toBeLessThanOrEqual(2);
const afterReflow = await snapshotAnchor(timeline);
expect(afterReflow.anchorId).toBe(before.anchorId);
expectAnchorOrderUnchanged(before, afterReflow);
const drift = await stopAnchorDriftSampler(timeline);
console.info("timeline-no-shift result", JSON.stringify(drift));
expect(drift.samples).toBeGreaterThan(0);
expect(drift.missingSamples).toBe(0);
expect(drift.maxDrift).toBeLessThanOrEqual(2);
});
test("de-virtualized timeline rows apply content-visibility", async ({
page,
}, testInfo) => {
testInfo.setTimeout(45_000);
await installMockBridge(page);
await page.goto("/");
await waitForMockTimelineBridge(page);
await seedNoShiftTimeline(page);
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const timeline = page.getByTestId("message-timeline");
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
// Guards against a typo'd/removed wrapper class shipping inert — a bad
// utility name is invisible to typecheck.
const hasContentVisibility = await timeline
.locator("[data-message-id]")
.first()
.evaluate((element) => {
const scroller = element.closest('[data-testid="message-timeline"]');
let node: HTMLElement | null = element.parentElement;
while (node && node !== scroller) {
if (getComputedStyle(node).contentVisibility === "auto") return true;
node = node.parentElement;
}
return false;
});
expect(hasContentVisibility).toBe(true);
});
test("thread panel late row reflow keeps the reading reply stable", async ({
page,
}, testInfo) => {
testInfo.setTimeout(45_000);
await installMockBridge(page);
await page.goto("/");
await waitForMockTimelineBridge(page);
const rootId = await page.evaluate(() => {
const root = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: "thread no-shift root",
createdAt: 1_700_300_000,
});
if (!root) throw new Error("Failed to seed thread no-shift root");
for (let index = 0; index < 48; index += 1) {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: `thread no-shift reply ${index}\nsecond line ${index}\nthird line ${index}`,
parentEventId: root.id,
createdAt: 1_700_300_001 + index,
});
}
return root.id;
});
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const timeline = page.getByTestId("message-timeline");
const summary = timeline.locator(
`[data-testid="message-thread-summary"][data-thread-head-id="${rootId}"]`,
);
await expect(summary).toBeVisible({ timeout: 5_000 });
await summary.click();
const threadPanel = page.getByTestId("message-thread-panel");
await expect(threadPanel).toBeVisible();
await expect(threadPanel.getByTestId("message-thread-head")).toContainText(
"thread no-shift root",
);
const threadBody = threadPanel.getByTestId("message-thread-body");
await expect(threadBody.locator("[data-message-id]").first()).toBeVisible();
await page.waitForFunction(() => {
const element = document.querySelector(
'[data-testid="message-thread-body"]',
) as HTMLDivElement | null;
return element && element.scrollHeight > element.clientHeight + 800;
});
await threadBody.evaluate((element) => {
const scroller = element as HTMLDivElement;
scroller.scrollTop = Math.floor(scroller.scrollHeight / 2);
scroller.dispatchEvent(new Event("scroll", { bubbles: true }));
});
await page.waitForTimeout(100);
const before = await snapshotAnchor(threadBody);
expect(before.anchorId).not.toBe("");
await startAnchorDriftSampler(threadBody, before.anchorId, before.anchorTop);
const grew = await growRowAboveAnchor(threadBody, before.anchorId);
expect(grew).toBe(true);
await expect
.poll(
async () => {
const top = await getAnchorTop(threadBody, before.anchorId);
return top === null
? Number.POSITIVE_INFINITY
: Math.abs(top - before.anchorTop);
},
{ timeout: 3_000 },
)
.toBeLessThanOrEqual(2);
await page.waitForTimeout(500);
const afterReflow = await snapshotAnchor(threadBody);
expect(afterReflow.anchorId).toBe(before.anchorId);
expectAnchorOrderUnchanged(before, afterReflow);
const drift = await stopAnchorDriftSampler(threadBody);
console.info("thread-panel-no-shift result", JSON.stringify(drift));
expect(drift.samples).toBeGreaterThan(0);
expect(drift.missingSamples).toBe(0);
expect(drift.maxDrift).toBeLessThanOrEqual(2);
});