mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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:
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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]",
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
429
desktop/tests/e2e/timeline-no-shift.spec.ts
Normal file
429
desktop/tests/e2e/timeline-no-shift.spec.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user