mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(desktop): re-land virtualized timeline to fix macOS beachball (#1250)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1067mpdna4vy22u9eltmmhtdfw56kww6ve2aze025dtfm4pxg07nqztjfhm <7ebdb0b67dab08a570b9faf7bbada97535673b4ccaba2cbd546ad3ba84c87fa6@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
@@ -669,9 +669,6 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
hasComposerOverlay={hasMainComposerOverlay}
|
||||
hasOlderMessages={hasOlderMessages}
|
||||
isFetchingOlder={isFetchingOlder}
|
||||
layoutShiftKey={
|
||||
useSplitAuxiliaryPane ? (openThreadHeadId ?? "closed") : "overlay"
|
||||
}
|
||||
isFollowingThreadById={isFollowingThreadById}
|
||||
isMessageUnreadById={isMessageUnreadById}
|
||||
personaLookup={personaLookup}
|
||||
|
||||
210
desktop/src/features/messages/lib/scrollConvergence.test.mjs
Normal file
210
desktop/src/features/messages/lib/scrollConvergence.test.mjs
Normal file
@@ -0,0 +1,210 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { CONVERGENCE_FRAME_CAP, convergenceStep } from "./scrollConvergence.ts";
|
||||
|
||||
function input(overrides) {
|
||||
return {
|
||||
targetMessageId: "target",
|
||||
indexByMessageId: new Map([["target", 100]]),
|
||||
lastIssuedIndex: null,
|
||||
librarySettled: false,
|
||||
stalledOffTarget: false,
|
||||
framesUsed: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// --- re-aim / staleness guard ------------------------------------------------
|
||||
|
||||
test("convergenceStep: first frame aims at the resolved index, not yet done", () => {
|
||||
const step = convergenceStep(input({ lastIssuedIndex: null }));
|
||||
assert.equal(step.nextIndex, 100);
|
||||
assert.equal(step.done, false);
|
||||
assert.equal(step.converged, false);
|
||||
});
|
||||
|
||||
test("convergenceStep: re-resolves a shifted index from the map each frame", () => {
|
||||
// A prepend shifted the target from 100 to 105. The library is still chasing
|
||||
// the old index (lastIssuedIndex 100); the reducer must aim at the NEW index
|
||||
// so the adapter re-issues scrollToIndex(105). This is the staleness guard.
|
||||
const step = convergenceStep(
|
||||
input({
|
||||
indexByMessageId: new Map([["target", 105]]),
|
||||
lastIssuedIndex: 100,
|
||||
}),
|
||||
);
|
||||
assert.equal(step.nextIndex, 105);
|
||||
assert.equal(step.done, false);
|
||||
assert.equal(step.converged, false);
|
||||
});
|
||||
|
||||
test("convergenceStep: target removed mid-settle stops with converged=false", () => {
|
||||
// Target deleted from the map while the loop was chasing it. Terminate so the
|
||||
// adapter clears the highlight instead of chasing a vanished row.
|
||||
const step = convergenceStep(
|
||||
input({
|
||||
indexByMessageId: new Map(), // target gone
|
||||
lastIssuedIndex: 100,
|
||||
framesUsed: 3,
|
||||
}),
|
||||
);
|
||||
assert.equal(step.nextIndex, null);
|
||||
assert.equal(step.done, true);
|
||||
assert.equal(step.converged, false);
|
||||
});
|
||||
|
||||
// --- settle ------------------------------------------------------------------
|
||||
|
||||
test("convergenceStep: library settled while aiming at current index converges", () => {
|
||||
const step = convergenceStep(
|
||||
input({ lastIssuedIndex: 100, librarySettled: true }),
|
||||
);
|
||||
assert.equal(step.nextIndex, 100);
|
||||
assert.equal(step.done, true);
|
||||
assert.equal(step.converged, true);
|
||||
});
|
||||
|
||||
test("convergenceStep: a settle reported WHILE re-aiming is ignored", () => {
|
||||
// The index just moved (105) but the library reports settled — that settle is
|
||||
// on the OLD index (100), so it must NOT count as convergence. The reducer
|
||||
// keeps going and aims at the new index.
|
||||
const step = convergenceStep(
|
||||
input({
|
||||
indexByMessageId: new Map([["target", 105]]),
|
||||
lastIssuedIndex: 100,
|
||||
librarySettled: true,
|
||||
}),
|
||||
);
|
||||
assert.equal(step.nextIndex, 105);
|
||||
assert.equal(step.done, false);
|
||||
assert.equal(step.converged, false);
|
||||
});
|
||||
|
||||
test("convergenceStep: aiming at current but not yet settled keeps waiting", () => {
|
||||
// Library is chasing the right index but its offset hasn't stabilized. The
|
||||
// reducer returns the same index (so the adapter re-issues NOTHING — issuing
|
||||
// would reset the library's stableFrames and prevent settling) and waits.
|
||||
const step = convergenceStep(
|
||||
input({ lastIssuedIndex: 100, librarySettled: false }),
|
||||
);
|
||||
assert.equal(step.nextIndex, 100);
|
||||
assert.equal(step.reissue, false);
|
||||
assert.equal(step.done, false);
|
||||
assert.equal(step.converged, false);
|
||||
});
|
||||
|
||||
// --- off-target stall (liveness) ---------------------------------------------
|
||||
|
||||
test("convergenceStep: stalled off-target while aiming at current re-issues same index", () => {
|
||||
// The library's offset stopped moving but never reached the current index's
|
||||
// target (its internal reconcile deadlocked after rows re-measured). The
|
||||
// reducer signals a same-index re-issue to kick it — the loop continues.
|
||||
const step = convergenceStep(
|
||||
input({ lastIssuedIndex: 100, stalledOffTarget: true }),
|
||||
);
|
||||
assert.equal(step.nextIndex, 100);
|
||||
assert.equal(step.reissue, true);
|
||||
assert.equal(step.done, false);
|
||||
assert.equal(step.converged, false);
|
||||
});
|
||||
|
||||
test("convergenceStep: a stall reported WHILE re-aiming does not re-issue", () => {
|
||||
// The index just moved (105) but the library reports a stall on the OLD index
|
||||
// (100). The reducer re-aims at the new index normally; the stale stall must
|
||||
// NOT trigger a same-index kick (there is no current-index stall to kick).
|
||||
const step = convergenceStep(
|
||||
input({
|
||||
indexByMessageId: new Map([["target", 105]]),
|
||||
lastIssuedIndex: 100,
|
||||
stalledOffTarget: true,
|
||||
}),
|
||||
);
|
||||
assert.equal(step.nextIndex, 105);
|
||||
assert.equal(step.reissue, false);
|
||||
assert.equal(step.done, false);
|
||||
assert.equal(step.converged, false);
|
||||
});
|
||||
|
||||
test("convergenceStep: a settle takes priority over a concurrent stall flag", () => {
|
||||
// Defensive: the adapter computes settle and stall as mutually exclusive, but
|
||||
// if both arrive, a genuine settle must win (converge) rather than spin on a
|
||||
// pointless re-issue.
|
||||
const step = convergenceStep(
|
||||
input({
|
||||
lastIssuedIndex: 100,
|
||||
librarySettled: true,
|
||||
stalledOffTarget: true,
|
||||
}),
|
||||
);
|
||||
assert.equal(step.done, true);
|
||||
assert.equal(step.converged, true);
|
||||
assert.equal(step.reissue, false);
|
||||
});
|
||||
|
||||
// --- frame cap ---------------------------------------------------------------
|
||||
|
||||
test("convergenceStep: terminates at the frame cap without converging", () => {
|
||||
// A row that never settles (librarySettled stays false) must still stop at the
|
||||
// cap rather than spin forever.
|
||||
const step = convergenceStep(
|
||||
input({
|
||||
lastIssuedIndex: 100,
|
||||
librarySettled: false,
|
||||
framesUsed: CONVERGENCE_FRAME_CAP - 1,
|
||||
}),
|
||||
);
|
||||
assert.equal(step.done, true);
|
||||
assert.equal(step.converged, false);
|
||||
assert.equal(step.nextIndex, 100);
|
||||
});
|
||||
|
||||
test("convergenceStep: frame cap bounds a perpetually shifting target", () => {
|
||||
// Drive the loop the way the adapter would: the target index moves every
|
||||
// frame, so the library never settles. The loop must terminate at the cap.
|
||||
let lastIssuedIndex = null;
|
||||
let framesUsed = 0;
|
||||
let done = false;
|
||||
let converged = true;
|
||||
|
||||
while (framesUsed < CONVERGENCE_FRAME_CAP + 5) {
|
||||
const movingIndex = 100 + framesUsed; // shifts every frame
|
||||
const step = convergenceStep(
|
||||
input({
|
||||
indexByMessageId: new Map([["target", movingIndex]]),
|
||||
lastIssuedIndex,
|
||||
librarySettled: false,
|
||||
framesUsed,
|
||||
}),
|
||||
);
|
||||
lastIssuedIndex = step.nextIndex;
|
||||
framesUsed += 1;
|
||||
if (step.done) {
|
||||
done = step.done;
|
||||
converged = step.converged;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
assert.equal(done, true);
|
||||
assert.equal(converged, false);
|
||||
assert.ok(framesUsed <= CONVERGENCE_FRAME_CAP);
|
||||
});
|
||||
|
||||
test("convergenceStep: converges once a re-aimed index then settles", () => {
|
||||
// Realistic flow: frame 0 aims (lastIssued null -> 100), frame 1 the library
|
||||
// is chasing 100 and reports settled -> converged.
|
||||
const aim = convergenceStep(input({ lastIssuedIndex: null }));
|
||||
assert.equal(aim.nextIndex, 100);
|
||||
assert.equal(aim.done, false);
|
||||
|
||||
const settle = convergenceStep(
|
||||
input({
|
||||
lastIssuedIndex: aim.nextIndex,
|
||||
librarySettled: true,
|
||||
framesUsed: 1,
|
||||
}),
|
||||
);
|
||||
assert.equal(settle.done, true);
|
||||
assert.equal(settle.converged, true);
|
||||
});
|
||||
151
desktop/src/features/messages/lib/scrollConvergence.ts
Normal file
151
desktop/src/features/messages/lib/scrollConvergence.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Pure staleness + termination decision for scrolling a virtualized timeline to
|
||||
* a message that may be far off-screen.
|
||||
*
|
||||
* @tanstack/react-virtual already owns the OFFSET convergence: a single
|
||||
* `scrollToIndex(index)` captures that index in `scrollState`, and its internal
|
||||
* `reconcileScroll` rAF loop re-runs `getOffsetForIndex(index)` every frame —
|
||||
* re-aiming as off-screen rows mount and `measureElement` corrects their
|
||||
* heights — until the offset is stable (or a 5s safety valve fires). We do NOT
|
||||
* recompute offsets; duplicating `getOffsetForIndex` against the library's own
|
||||
* `measurementsCache`/`scrollMargin`/`scrollPadding` would only drift.
|
||||
*
|
||||
* What the library does NOT do: it chases the INDEX captured at call time, with
|
||||
* no concept of a message id. If the data shifts mid-settle — a prepend or a
|
||||
* delete above the target — the captured index now points at the wrong row and
|
||||
* the library happily settles on it. This reducer owns exactly that gap: each
|
||||
* frame it re-resolves the target's CURRENT index from the live map and decides
|
||||
* whether the adapter must re-aim the library, let it settle, or stop.
|
||||
*
|
||||
* Two correctness properties this enforces and the `.mjs` suite gates:
|
||||
* - The target index is re-resolved by id every frame (never frozen), so a
|
||||
* concurrent prepend/delete that shifts the target re-aims the library at the
|
||||
* new index instead of stranding it on the old one.
|
||||
* - If the target id leaves the data mid-settle (deleted), the loop terminates
|
||||
* with `converged: false` rather than chasing a vanished row to the cap.
|
||||
*
|
||||
* Plus one liveness property: a large windowed-out jump can leave the library's
|
||||
* own reconcile deadlocked off-target (offset stable but short of the target
|
||||
* after rows re-measured under it). When that happens the reducer signals a
|
||||
* same-index re-issue (`reissue: true`) to restart the library's reconcile —
|
||||
* the single case where re-issuing an unchanged index is correct.
|
||||
*/
|
||||
|
||||
/** Where a scroll target should land in the viewport. Mirrors the library's align. */
|
||||
export type ConvergenceAlign = "start" | "center" | "end";
|
||||
|
||||
export type ConvergenceInput = {
|
||||
/** Id of the message to settle on — re-resolved against the map each frame. */
|
||||
targetMessageId: string;
|
||||
/** Live message-id -> item-index map; re-read every frame (staleness guard). */
|
||||
indexByMessageId: Map<string, number>;
|
||||
/**
|
||||
* Index the library is currently chasing (the last index the adapter issued
|
||||
* via `scrollToIndex`), or `null` before the first issue. Lets the reducer
|
||||
* tell a re-aim (index moved) from a steady settle (index unchanged).
|
||||
*/
|
||||
lastIssuedIndex: number | null;
|
||||
/**
|
||||
* Whether the library's offset reached the current index's target this frame
|
||||
* and stopped moving. Only meaningful once the library is chasing the CURRENT
|
||||
* index; a settle reported while re-aiming is ignored.
|
||||
*/
|
||||
librarySettled: boolean;
|
||||
/**
|
||||
* Whether the library's offset has stopped moving but is NOT at the current
|
||||
* index's target — it stalled mid-reconcile (its internal re-aim deadlocked
|
||||
* after rows re-measured under it). The reducer kicks it with a fresh re-issue
|
||||
* at the same index. Mutually exclusive with `librarySettled`.
|
||||
*/
|
||||
stalledOffTarget: boolean;
|
||||
/** Frames already spent in the loop (the adapter increments per rAF). */
|
||||
framesUsed: number;
|
||||
};
|
||||
|
||||
export type ConvergenceDecision = {
|
||||
/**
|
||||
* Index the adapter should be aiming the library at, or `null` when the
|
||||
* target is gone. The adapter only re-issues `scrollToIndex` when this differs
|
||||
* from `lastIssuedIndex`, so a steady settle issues no redundant scroll (which
|
||||
* would reset the library's `stableFrames` and prevent it from ever settling).
|
||||
*/
|
||||
nextIndex: number | null;
|
||||
/**
|
||||
* True when the adapter must re-issue `scrollToIndex(nextIndex)` even though
|
||||
* the index is unchanged — used to kick the library out of an off-target
|
||||
* stall. A normal steady settle leaves this false so no redundant scroll
|
||||
* resets the library's `stableFrames`.
|
||||
*/
|
||||
reissue: boolean;
|
||||
/** True once the loop must stop (settled, target gone, or frame cap hit). */
|
||||
done: boolean;
|
||||
/** True only when the loop stopped because the target row actually settled. */
|
||||
converged: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hard cap on frames so a perpetually re-measuring row, or a target whose index
|
||||
* keeps shifting, can't spin the loop forever. The library has its own 5s valve;
|
||||
* this is the adapter-side bound expressed in frames for deterministic testing.
|
||||
*/
|
||||
export const CONVERGENCE_FRAME_CAP = 32;
|
||||
|
||||
/**
|
||||
* One frame of the convergence loop. Pure: given the live map and the library's
|
||||
* settle state, decides the index to aim at and whether to stop.
|
||||
*/
|
||||
export function convergenceStep(input: ConvergenceInput): ConvergenceDecision {
|
||||
const currentIndex = input.indexByMessageId.get(input.targetMessageId);
|
||||
|
||||
// Target left the data mid-settle (deleted) — stop without converging so the
|
||||
// adapter clears the highlight instead of chasing a vanished row.
|
||||
if (currentIndex === undefined) {
|
||||
return { nextIndex: null, reissue: false, done: true, converged: false };
|
||||
}
|
||||
|
||||
const aimingAtCurrent = input.lastIssuedIndex === currentIndex;
|
||||
|
||||
// The library only settles meaningfully once it is chasing the CURRENT index.
|
||||
// A settle reported while we are still re-aiming (index just moved) is stale.
|
||||
if (aimingAtCurrent && input.librarySettled) {
|
||||
return {
|
||||
nextIndex: currentIndex,
|
||||
reissue: false,
|
||||
done: true,
|
||||
converged: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Frame cap: accept the best index we have rather than spin forever on a row
|
||||
// whose height never settles or a target whose index keeps shifting.
|
||||
if (input.framesUsed + 1 >= CONVERGENCE_FRAME_CAP) {
|
||||
return {
|
||||
nextIndex: currentIndex,
|
||||
reissue: false,
|
||||
done: true,
|
||||
converged: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Library stalled off-target: its offset stopped moving but never reached the
|
||||
// current index (its internal reconcile deadlocked after rows re-measured).
|
||||
// Re-issue the SAME index to restart its reconcile — the one case where a
|
||||
// same-index re-issue is correct rather than a stableFrames-resetting bug.
|
||||
if (aimingAtCurrent && input.stalledOffTarget) {
|
||||
return {
|
||||
nextIndex: currentIndex,
|
||||
reissue: true,
|
||||
done: false,
|
||||
converged: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Either the index moved (adapter will re-issue scrollToIndex) or the library
|
||||
// is still settling on the current index (adapter issues nothing, just waits).
|
||||
return {
|
||||
nextIndex: currentIndex,
|
||||
reissue: false,
|
||||
done: false,
|
||||
converged: false,
|
||||
};
|
||||
}
|
||||
231
desktop/src/features/messages/lib/timelineItems.test.mjs
Normal file
231
desktop/src/features/messages/lib/timelineItems.test.mjs
Normal file
@@ -0,0 +1,231 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";
|
||||
import {
|
||||
buildTimelineItems,
|
||||
getTimelineItemKey,
|
||||
resolveActiveDayTimestamp,
|
||||
} from "./timelineItems.ts";
|
||||
|
||||
function dayAt(year, month, day, hour = 12) {
|
||||
return Math.floor(
|
||||
new Date(year, month - 1, day, hour, 0, 0).getTime() / 1_000,
|
||||
);
|
||||
}
|
||||
|
||||
function message(overrides) {
|
||||
return {
|
||||
id: "m",
|
||||
renderKey: undefined,
|
||||
createdAt: dayAt(2026, 6, 14),
|
||||
pubkey: "author",
|
||||
parentId: null,
|
||||
rootId: null,
|
||||
depth: 0,
|
||||
kind: 9,
|
||||
tags: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// The builder takes MainTimelineEntry[] (post top-level filter); summary is
|
||||
// irrelevant to item/divider placement, so null is fine here.
|
||||
function entry(overrides) {
|
||||
return { message: message(overrides), summary: null };
|
||||
}
|
||||
|
||||
function kinds(items) {
|
||||
return items.map((item) => item.kind);
|
||||
}
|
||||
|
||||
// --- divider placement -------------------------------------------------------
|
||||
|
||||
test("buildTimelineItems: 3-day channel with unread mid-day-2 places dividers by index", () => {
|
||||
const entries = [
|
||||
entry({ id: "d1a", createdAt: dayAt(2026, 6, 12) }),
|
||||
entry({ id: "d1b", createdAt: dayAt(2026, 6, 12, 13) }),
|
||||
entry({ id: "d2a", createdAt: dayAt(2026, 6, 13) }),
|
||||
entry({ id: "d2b", createdAt: dayAt(2026, 6, 13, 13) }), // first unread
|
||||
entry({ id: "d2c", createdAt: dayAt(2026, 6, 13, 14) }),
|
||||
entry({ id: "d3a", createdAt: dayAt(2026, 6, 14) }),
|
||||
];
|
||||
|
||||
const { items } = buildTimelineItems(entries, "d2b");
|
||||
|
||||
assert.deepEqual(kinds(items), [
|
||||
"day-divider", // day 1
|
||||
"message", // d1a
|
||||
"message", // d1b
|
||||
"day-divider", // day 2
|
||||
"message", // d2a
|
||||
"unread-divider", // above d2b
|
||||
"message", // d2b
|
||||
"message", // d2c
|
||||
"day-divider", // day 3
|
||||
"message", // d3a
|
||||
]);
|
||||
});
|
||||
|
||||
test("buildTimelineItems: unread divider suppressed when first unread is the first entry", () => {
|
||||
const entries = [
|
||||
entry({ id: "a", createdAt: dayAt(2026, 6, 14) }),
|
||||
entry({ id: "b", createdAt: dayAt(2026, 6, 14, 13) }),
|
||||
];
|
||||
// firstUnread === index 0 — nothing above it, so no divider.
|
||||
const { items } = buildTimelineItems(entries, "a");
|
||||
assert.equal(items.filter((i) => i.kind === "unread-divider").length, 0);
|
||||
});
|
||||
|
||||
test("buildTimelineItems: system messages flatten to a 'system' item", () => {
|
||||
const entries = [
|
||||
entry({ id: "a", createdAt: dayAt(2026, 6, 14) }),
|
||||
entry({
|
||||
id: "sys",
|
||||
kind: KIND_SYSTEM_MESSAGE,
|
||||
createdAt: dayAt(2026, 6, 14, 13),
|
||||
}),
|
||||
];
|
||||
const { items } = buildTimelineItems(entries, null);
|
||||
assert.deepEqual(kinds(items), ["day-divider", "message", "system"]);
|
||||
});
|
||||
|
||||
test("buildTimelineItems: empty entries produce no items and an empty map", () => {
|
||||
const { items, indexByMessageId } = buildTimelineItems([], null);
|
||||
assert.equal(items.length, 0);
|
||||
assert.equal(indexByMessageId.size, 0);
|
||||
});
|
||||
|
||||
// --- index map correctness ---------------------------------------------------
|
||||
|
||||
test("buildTimelineItems: map points each message id at its flattened item index", () => {
|
||||
const entries = [
|
||||
entry({ id: "d1", createdAt: dayAt(2026, 6, 12) }),
|
||||
entry({ id: "d2", createdAt: dayAt(2026, 6, 13) }),
|
||||
];
|
||||
const { items, indexByMessageId } = buildTimelineItems(entries, null);
|
||||
|
||||
// dividers occupy indices 0 and 2; messages land at 1 and 3.
|
||||
assert.equal(indexByMessageId.get("d1"), 1);
|
||||
assert.equal(indexByMessageId.get("d2"), 3);
|
||||
assert.equal(items[1].entry.message.id, "d1");
|
||||
assert.equal(items[3].entry.message.id, "d2");
|
||||
});
|
||||
|
||||
test("buildTimelineItems: appending a new message keeps prior indices stable", () => {
|
||||
const base = [entry({ id: "a", createdAt: dayAt(2026, 6, 14) })];
|
||||
const before = buildTimelineItems(base, null).indexByMessageId;
|
||||
|
||||
const appended = [
|
||||
...base,
|
||||
entry({ id: "b", createdAt: dayAt(2026, 6, 14, 13) }),
|
||||
];
|
||||
const after = buildTimelineItems(appended, null).indexByMessageId;
|
||||
|
||||
assert.equal(after.get("a"), before.get("a"));
|
||||
assert.equal(after.get("b"), 2);
|
||||
});
|
||||
|
||||
test("buildTimelineItems: prepending an older-day message shifts later indices", () => {
|
||||
const original = [entry({ id: "b", createdAt: dayAt(2026, 6, 14) })];
|
||||
const beforeIdx = buildTimelineItems(original, null).indexByMessageId.get(
|
||||
"b",
|
||||
);
|
||||
|
||||
// Prepend a message on an earlier day → adds its own day-divider + message,
|
||||
// pushing "b" (now on a new day boundary too) further down.
|
||||
const prepended = [
|
||||
entry({ id: "a", createdAt: dayAt(2026, 6, 13) }),
|
||||
entry({ id: "b", createdAt: dayAt(2026, 6, 14) }),
|
||||
];
|
||||
const afterIdx = buildTimelineItems(prepended, null).indexByMessageId.get(
|
||||
"b",
|
||||
);
|
||||
assert.ok(afterIdx > beforeIdx);
|
||||
});
|
||||
|
||||
test("buildTimelineItems: deleting a message drops it from the map", () => {
|
||||
const entries = [
|
||||
entry({ id: "a", createdAt: dayAt(2026, 6, 14) }),
|
||||
entry({ id: "b", createdAt: dayAt(2026, 6, 14, 13) }),
|
||||
];
|
||||
const afterDelete = buildTimelineItems(
|
||||
entries.filter((e) => e.message.id !== "a"),
|
||||
null,
|
||||
).indexByMessageId;
|
||||
assert.equal(afterDelete.has("a"), false);
|
||||
assert.equal(afterDelete.get("b"), 1);
|
||||
});
|
||||
|
||||
test("getTimelineItemKey: keys are unique across the stream", () => {
|
||||
const entries = [
|
||||
entry({ id: "a", createdAt: dayAt(2026, 6, 12) }),
|
||||
entry({ id: "b", createdAt: dayAt(2026, 6, 13) }),
|
||||
];
|
||||
const { items } = buildTimelineItems(entries, "b");
|
||||
const keys = items.map(getTimelineItemKey);
|
||||
assert.equal(new Set(keys).size, keys.length);
|
||||
});
|
||||
|
||||
test("resolveActiveDayTimestamp: null index returns null (nothing in view yet)", () => {
|
||||
const { items } = buildTimelineItems(
|
||||
[entry({ id: "a", createdAt: dayAt(2026, 6, 14) })],
|
||||
null,
|
||||
);
|
||||
assert.equal(resolveActiveDayTimestamp(items, null), null);
|
||||
});
|
||||
|
||||
test("resolveActiveDayTimestamp: empty stream returns null", () => {
|
||||
assert.equal(resolveActiveDayTimestamp([], 0), null);
|
||||
});
|
||||
|
||||
test("resolveActiveDayTimestamp: a top row resolves to its own day's heading", () => {
|
||||
const ts = dayAt(2026, 6, 14, 9);
|
||||
const { items } = buildTimelineItems(
|
||||
[entry({ id: "a", createdAt: ts })],
|
||||
null,
|
||||
);
|
||||
// items[0] is the day-divider, items[1] is the message. The message at index
|
||||
// 1 resolves back to the divider above it.
|
||||
assert.equal(resolveActiveDayTimestamp(items, 1), items[0].headingTimestamp);
|
||||
});
|
||||
|
||||
test("resolveActiveDayTimestamp: a later-day row whose divider scrolled out still resolves to that later day", () => {
|
||||
const day1 = dayAt(2026, 6, 13, 9);
|
||||
const day2 = dayAt(2026, 6, 14, 9);
|
||||
const { items } = buildTimelineItems(
|
||||
[entry({ id: "a", createdAt: day1 }), entry({ id: "b", createdAt: day2 })],
|
||||
null,
|
||||
);
|
||||
// Stream: [div(day1), a, div(day2), b]. Topmost visible = b (index 3); the
|
||||
// day2 divider at index 2 is the nearest divider at or above it.
|
||||
const dividerDay2 = items[2];
|
||||
assert.equal(dividerDay2.kind, "day-divider");
|
||||
assert.equal(
|
||||
resolveActiveDayTimestamp(items, 3),
|
||||
dividerDay2.headingTimestamp,
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveActiveDayTimestamp: index past the end clamps to the last item", () => {
|
||||
const ts = dayAt(2026, 6, 14, 9);
|
||||
const { items } = buildTimelineItems(
|
||||
[entry({ id: "a", createdAt: ts })],
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
resolveActiveDayTimestamp(items, 999),
|
||||
items[0].headingTimestamp,
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveActiveDayTimestamp: a divider row at the top resolves to its own heading", () => {
|
||||
const ts = dayAt(2026, 6, 14, 9);
|
||||
const { items } = buildTimelineItems(
|
||||
[entry({ id: "a", createdAt: ts })],
|
||||
null,
|
||||
);
|
||||
// Topmost visible is the divider itself (index 0) — it resolves to itself.
|
||||
assert.equal(items[0].kind, "day-divider");
|
||||
assert.equal(resolveActiveDayTimestamp(items, 0), items[0].headingTimestamp);
|
||||
});
|
||||
120
desktop/src/features/messages/lib/timelineItems.ts
Normal file
120
desktop/src/features/messages/lib/timelineItems.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Flattens the heterogeneous day-grouped timeline tree into a flat
|
||||
* discriminated-union item stream that a virtualizer can window over, and
|
||||
* builds the `messageId -> itemIndex` map every DOM-query scroll path now
|
||||
* resolves against instead of `querySelector`.
|
||||
*
|
||||
* Kept pure (no React, no DOM) so it is covered by the lib-level `*.test.mjs`
|
||||
* suite. The list and the index map are produced together from the SAME walk,
|
||||
* so they can never drift: a stale map would scroll deep-links to the wrong
|
||||
* row, the exact failure virtualization risks.
|
||||
*/
|
||||
|
||||
import {
|
||||
buildDayGroupBoundaries,
|
||||
type DayGroupBoundary,
|
||||
} from "@/features/messages/lib/timelineSnapshot";
|
||||
import { shouldRenderUnreadDivider } from "@/features/messages/lib/threadPanel";
|
||||
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
|
||||
import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";
|
||||
|
||||
/**
|
||||
* One renderable row in the flattened timeline. Dividers carry no message and
|
||||
* never appear in the index map; the three message-bearing kinds do.
|
||||
*/
|
||||
export type TimelineItem =
|
||||
// `headingTimestamp` (not a prebaked label) so the render still resolves
|
||||
// "Today"/"Yesterday" relative to the current clock, not to build time.
|
||||
| { kind: "day-divider"; key: string; headingTimestamp: number }
|
||||
| { kind: "unread-divider"; key: string }
|
||||
| { kind: "system"; key: string; entry: MainTimelineEntry }
|
||||
| { kind: "message"; key: string; entry: MainTimelineEntry };
|
||||
|
||||
export type TimelineItemsResult = {
|
||||
items: TimelineItem[];
|
||||
/** Maps a top-level message id to its index in `items`. */
|
||||
indexByMessageId: Map<string, number>;
|
||||
};
|
||||
|
||||
/** Stable per-item key, unique across the flattened stream. */
|
||||
export function getTimelineItemKey(item: TimelineItem): string {
|
||||
return item.key;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `headingTimestamp` of the day the topmost visible row belongs to: the
|
||||
* nearest day-divider at or above `topVisibleIndex`. The floating active-day
|
||||
* header reads this so it always shows the current day even when that day's
|
||||
* in-stream divider has scrolled out of the virtual window. Returns `null`
|
||||
* before any row is in view or when no divider precedes the visible row (an
|
||||
* empty stream).
|
||||
*/
|
||||
export function resolveActiveDayTimestamp(
|
||||
items: TimelineItem[],
|
||||
topVisibleIndex: number | null,
|
||||
): number | null {
|
||||
if (topVisibleIndex === null) {
|
||||
return null;
|
||||
}
|
||||
for (let i = Math.min(topVisibleIndex, items.length - 1); i >= 0; i--) {
|
||||
const item = items[i];
|
||||
if (item.kind === "day-divider") {
|
||||
return item.headingTimestamp;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function entryRenderKey(entry: MainTimelineEntry): string {
|
||||
return entry.message.renderKey ?? entry.message.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks the (already top-level-filtered) entries once, emitting a day-divider
|
||||
* at each calendar-day boundary and an unread-divider above the first unread
|
||||
* message, then the message/system row itself. The index map records where
|
||||
* each message landed in the flat stream so scroll targets resolve in O(1)
|
||||
* without touching the DOM.
|
||||
*/
|
||||
export function buildTimelineItems(
|
||||
entries: MainTimelineEntry[],
|
||||
firstUnreadMessageId: string | null,
|
||||
): TimelineItemsResult {
|
||||
const items: TimelineItem[] = [];
|
||||
const indexByMessageId = new Map<string, number>();
|
||||
|
||||
// Index boundaries by their start position so the walk below can look up the
|
||||
// prepend-stable section key (start-of-local-day). Keying the divider by
|
||||
// start-of-day, not by the first message, keeps the day section from
|
||||
// remounting when older messages prepend into it.
|
||||
const dayBoundariesByStartIndex = new Map(
|
||||
buildDayGroupBoundaries(entries.map((entry) => entry.message)).map(
|
||||
(boundary: DayGroupBoundary) => [boundary.startIndex, boundary] as const,
|
||||
),
|
||||
);
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
const { message } = entry;
|
||||
const renderKey = entryRenderKey(entry);
|
||||
|
||||
const dayBoundary = dayBoundariesByStartIndex.get(i);
|
||||
if (dayBoundary) {
|
||||
items.push({
|
||||
kind: "day-divider",
|
||||
key: dayBoundary.key,
|
||||
headingTimestamp: message.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldRenderUnreadDivider(i, message.id, firstUnreadMessageId)) {
|
||||
items.push({ kind: "unread-divider", key: `unread-${renderKey}` });
|
||||
}
|
||||
|
||||
const kind = message.kind === KIND_SYSTEM_MESSAGE ? "system" : "message";
|
||||
indexByMessageId.set(message.id, items.length);
|
||||
items.push({ kind, key: renderKey, entry });
|
||||
}
|
||||
|
||||
return { items, indexByMessageId };
|
||||
}
|
||||
26
desktop/src/features/messages/ui/ActiveDayHeader.tsx
Normal file
26
desktop/src/features/messages/ui/ActiveDayHeader.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Floating active-day label for the virtualized timeline.
|
||||
*
|
||||
* The in-stream `DayDivider` rows mark each calendar boundary, but once a row
|
||||
* scrolls out of the virtual window it unmounts — so a `position: sticky`
|
||||
* divider can no longer hold the viewport top, and a sticky element inside the
|
||||
* scroll container drifts at scrollTop 0 (it reveals its natural flow offset
|
||||
* instead of the pinned offset). This label is therefore portaled by
|
||||
* `VirtualizedList` into a non-scrolling overlay container OUTSIDE the scroll
|
||||
* element (see `MessageTimeline`), where it pins to a fixed viewport offset and
|
||||
* cannot drift as older history prepends above the anchor. It is fed the day of
|
||||
* the topmost visible row so the label is always present regardless of which
|
||||
* dividers are currently mounted.
|
||||
*/
|
||||
export function ActiveDayHeader({ label }: { label: string }) {
|
||||
return (
|
||||
<p
|
||||
aria-hidden
|
||||
className="pointer-events-none mx-auto w-fit rounded-full border border-border/70 bg-background/95 px-2.5 py-1 text-2xs font-medium tracking-[0.02em] text-muted-foreground/70 shadow-xs backdrop-blur-sm"
|
||||
data-testid="message-timeline-active-day-header"
|
||||
data-day-label={label}
|
||||
>
|
||||
{label}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -378,10 +378,6 @@ export function MessageThreadPanel({
|
||||
}: MessageThreadPanelProps) {
|
||||
const threadBodyRef = React.useRef<HTMLDivElement>(null);
|
||||
const threadContentRef = React.useRef<HTMLDivElement>(null);
|
||||
// Threads don't paginate older history, so this sentinel is never observed
|
||||
// (the hook's older-history effect bails without a `fetchOlder`). It exists
|
||||
// only to satisfy the hook's required ref contract.
|
||||
const threadTopSentinelRef = React.useRef<HTMLDivElement>(null);
|
||||
const threadComposerWrapperRef = React.useRef<HTMLDivElement>(null);
|
||||
const [hoveredCollapseBranchId, setHoveredCollapseBranchId] = React.useState<
|
||||
string | null
|
||||
@@ -602,7 +598,6 @@ export function MessageThreadPanel({
|
||||
messages: threadMessages,
|
||||
onTargetReached: onScrollTargetResolved,
|
||||
scrollContainerRef: threadBodyRef,
|
||||
sentinelRef: threadTopSentinelRef,
|
||||
targetMessageId: scrollTargetId,
|
||||
});
|
||||
|
||||
@@ -622,7 +617,6 @@ export function MessageThreadPanel({
|
||||
ref={threadBodyRef}
|
||||
>
|
||||
<div ref={threadContentRef}>
|
||||
<div ref={threadTopSentinelRef} aria-hidden className="h-px" />
|
||||
<div
|
||||
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-1 pt-0")}
|
||||
data-testid="message-thread-head"
|
||||
@@ -736,7 +730,7 @@ export function MessageThreadPanel({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-0",
|
||||
"content-visibility-auto flex flex-col gap-0",
|
||||
entry.summary &&
|
||||
"group/message rounded-2xl px-0 py-0.5 transition-colors hover:bg-muted/50 focus-within:bg-muted/50",
|
||||
)}
|
||||
|
||||
@@ -10,17 +10,22 @@ import {
|
||||
import { getDmParticipantPreview } from "@/features/channels/lib/dmParticipantDisplay";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
|
||||
import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import type { ChannelType } from "@/shared/api/types";
|
||||
import { buildTimelineItems } from "@/features/messages/lib/timelineItems";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { channelChrome } from "@/shared/layout/chromeLayout";
|
||||
import { Spinner } from "@/shared/ui/spinner";
|
||||
import { TooltipProvider } from "@/shared/ui/tooltip";
|
||||
import { UnreadPill, unreadCountLabel } from "@/shared/ui/UnreadPill";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import type { ListVirtualizer } from "@/shared/ui/VirtualizedList";
|
||||
import { TimelineSkeleton, useTimelineSkeletonRows } from "./TimelineSkeleton";
|
||||
import { TimelineMessageList } from "./TimelineMessageList";
|
||||
import { useAnchoredScroll } from "./useAnchoredScroll";
|
||||
import { useConvergentScrollToMessage } from "./useConvergentScrollToMessage";
|
||||
import { useLoadOlderOnScroll } from "./useLoadOlderOnScroll";
|
||||
|
||||
export type MessageTimelineHandle = {
|
||||
scrollToBottomOnNextUpdate: () => void;
|
||||
@@ -50,7 +55,6 @@ type MessageTimelineProps = {
|
||||
/** True when the timeline has the composer overlay below it. */
|
||||
hasComposerOverlay?: boolean;
|
||||
isFetchingOlder?: boolean;
|
||||
layoutShiftKey?: string | number | null;
|
||||
messageFooters?: Record<string, React.ReactNode>;
|
||||
/** Map from lowercase pubkey → persona display name for bot members. */
|
||||
personaLookup?: Map<string, string>;
|
||||
@@ -149,7 +153,6 @@ const MessageTimelineBase = React.forwardRef<
|
||||
hasComposerOverlay = true,
|
||||
hasOlderMessages = true,
|
||||
isFetchingOlder = false,
|
||||
layoutShiftKey = null,
|
||||
followThreadById,
|
||||
isFollowingThreadById,
|
||||
isMessageUnreadById,
|
||||
@@ -183,6 +186,43 @@ const MessageTimelineBase = React.forwardRef<
|
||||
const scrollContainerRef = externalScrollRef ?? internalScrollRef;
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
const topSentinelRef = React.useRef<HTMLDivElement>(null);
|
||||
// The floating active-day header portals here — a non-scrolling sibling of
|
||||
// the scroll container — so it pins to a fixed offset and never drifts.
|
||||
const activeDayHeaderRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// The convergence fallback for a windowed-out deep-link target. It's defined
|
||||
// below (it depends on the anchored-scroll result), so `useAnchoredScroll`
|
||||
// reads it through a ref via a stable wrapper — letting the hook stay
|
||||
// virtualizer-agnostic while the consumer owns the convergence machinery.
|
||||
const convergeToTargetRef = React.useRef<(messageId: string) => boolean>(
|
||||
() => false,
|
||||
);
|
||||
const convergeToTarget = React.useCallback((messageId: string) => {
|
||||
return convergeToTargetRef.current(messageId);
|
||||
}, []);
|
||||
|
||||
// The mount bottom-pin driven through the virtualizer. Defined here as a
|
||||
// stable wrapper over a ref (assigned below, once the virtualizer/item count
|
||||
// are known) so `useAnchoredScroll` stays virtualizer-agnostic — same
|
||||
// indirection as `convergeToTarget`. Returns `true` once it issued the
|
||||
// index scroll; `false` (thread panel, no virtualizer) → the hook falls back
|
||||
// to its raw bottom pin.
|
||||
const pinToBottomByIndexRef = React.useRef<() => boolean>(() => false);
|
||||
const pinToBottomByIndex = React.useCallback(() => {
|
||||
return pinToBottomByIndexRef.current();
|
||||
}, []);
|
||||
|
||||
// The virtualizer instance is owned by the child TimelineMessageList (which
|
||||
// mounts the VirtualizedList) and reported up here via a ref so the scroll
|
||||
// manager can drive index-model scroll paths. The virtualizer arrives via
|
||||
// VirtualizedList's onVirtualizer layout effect (child layout effects fire
|
||||
// before parent layout effects), so getVirtualizer() is live by the time
|
||||
// useAnchoredScroll's mount pin runs.
|
||||
const virtualizerRef = React.useRef<ListVirtualizer | null>(null);
|
||||
const handleVirtualizer = React.useCallback((instance: ListVirtualizer) => {
|
||||
virtualizerRef.current = instance;
|
||||
}, []);
|
||||
const getVirtualizer = React.useCallback(() => virtualizerRef.current, []);
|
||||
|
||||
// Gate the heavy timeline render (each row runs a synchronous
|
||||
// react-markdown parse) behind React concurrency. `useDeferredValue` lets the
|
||||
@@ -207,6 +247,33 @@ const MessageTimelineBase = React.forwardRef<
|
||||
EMPTY_TIMELINE_SNAPSHOT,
|
||||
);
|
||||
const deferredMessages = deferredSnapshot.messages;
|
||||
// The flattened item stream mirrors what TimelineMessageList renders: use the
|
||||
// hoisted mainEntries when the deferred snapshot is current (same identity as
|
||||
// the live messages), fall back to building entries from deferredMessages when
|
||||
// the deferred value is stale. This keeps the scroll manager's index map in
|
||||
// sync with what's actually painted without a state-update round-trip.
|
||||
const deferredEntries = React.useMemo(
|
||||
() =>
|
||||
(deferredMessages === messages ? mainEntries : undefined) ??
|
||||
buildMainTimelineEntries(deferredMessages),
|
||||
[mainEntries, deferredMessages, messages],
|
||||
);
|
||||
const timelineItems = React.useMemo(
|
||||
() => buildTimelineItems(deferredEntries, firstUnreadMessageId),
|
||||
[deferredEntries, firstUnreadMessageId],
|
||||
);
|
||||
const virtualizerOption = React.useMemo(
|
||||
() =>
|
||||
timelineItems.items.length > 0
|
||||
? {
|
||||
getVirtualizer,
|
||||
indexByMessageId: timelineItems.indexByMessageId,
|
||||
itemCount: timelineItems.items.length,
|
||||
liveMessageCount: messages.length,
|
||||
}
|
||||
: null,
|
||||
[getVirtualizer, timelineItems, messages.length],
|
||||
);
|
||||
const isDeferredSnapshotStale = isDeferredTimelineSnapshotStale({
|
||||
deferredSnapshot,
|
||||
liveSnapshot,
|
||||
@@ -234,21 +301,21 @@ const MessageTimelineBase = React.forwardRef<
|
||||
isAtBottom,
|
||||
newMessageCount,
|
||||
onScroll,
|
||||
restoreScrollPosition,
|
||||
scrollToBottom,
|
||||
scrollToBottomOnNextUpdate,
|
||||
scrollToMessage,
|
||||
setLoadOlderRestoreInFlight,
|
||||
getAnchorIsAtBottom,
|
||||
} = useAnchoredScroll({
|
||||
channelId,
|
||||
contentRef,
|
||||
fetchOlder,
|
||||
hasOlderMessages,
|
||||
isFetchingOlder,
|
||||
convergeToTarget,
|
||||
isLoading: showTimelineSkeleton,
|
||||
layoutShiftKey,
|
||||
messages: deferredMessages,
|
||||
onTargetReached,
|
||||
pinToBottomByIndex,
|
||||
scrollContainerRef,
|
||||
sentinelRef: topSentinelRef,
|
||||
targetMessageId,
|
||||
});
|
||||
|
||||
@@ -279,6 +346,66 @@ const MessageTimelineBase = React.forwardRef<
|
||||
[scrollToBottomOnNextUpdate],
|
||||
);
|
||||
|
||||
// Role 3 — jump-to-message into windowed-out history. The DOM-based
|
||||
// `scrollToMessage` no-ops when the target row isn't mounted (virtualized
|
||||
// out), so when it fails and the timeline is virtualized we drive the
|
||||
// convergence adapter: it scrolls the virtualizer to the target index,
|
||||
// re-aiming each frame as rows mount and measure, then on settle the row is
|
||||
// in the DOM and `scrollToMessage` centers + highlights it. When there's no
|
||||
// virtualizer (e.g. the thread panel), there's nothing to converge — the DOM
|
||||
// path is the whole story and a missing row simply isn't reachable.
|
||||
const { scrollToMessage: convergeToMessage, cancel: cancelConvergence } =
|
||||
useConvergentScrollToMessage(getVirtualizer, {
|
||||
indexByMessageId: timelineItems.indexByMessageId,
|
||||
align: "center",
|
||||
onConverged: (messageId) => {
|
||||
scrollToMessage(messageId, { highlight: true });
|
||||
onTargetReached?.(messageId);
|
||||
},
|
||||
onAbandoned: (messageId) => onTargetReached?.(messageId),
|
||||
});
|
||||
const jumpToMessage = React.useCallback(
|
||||
(messageId: string, options?: { behavior?: ScrollBehavior }) => {
|
||||
if (scrollToMessage(messageId, { highlight: true, ...options })) {
|
||||
return;
|
||||
}
|
||||
if (virtualizerOption) {
|
||||
convergeToMessage(messageId);
|
||||
}
|
||||
},
|
||||
[convergeToMessage, scrollToMessage, virtualizerOption],
|
||||
);
|
||||
// Feed the windowed-out deep-link fallback back into `useAnchoredScroll`,
|
||||
// which calls it when a target row isn't in the DOM. Gated on the virtualizer
|
||||
// so the thread panel (no virtualizer) never converges. Assigned in an effect
|
||||
// because `useAnchoredScroll` reads it asynchronously from a post-mount effect.
|
||||
React.useEffect(() => {
|
||||
convergeToTargetRef.current = virtualizerOption
|
||||
? convergeToMessage
|
||||
: () => false;
|
||||
}, [convergeToMessage, virtualizerOption]);
|
||||
// Drive the mount bottom-pin through the virtualizer when one is present.
|
||||
// Assigned during render (not an effect) because `useAnchoredScroll`'s
|
||||
// mount pin runs in a layout effect on the same commit — a passive effect
|
||||
// would assign too late. `virtualizerOption` is derived from `timelineItems`
|
||||
// via useMemo (no state update, no extra render), so it is non-null from the
|
||||
// first deferred commit that carries real messages. `getVirtualizer()` is
|
||||
// live by pin time because VirtualizedList publishes the instance in a
|
||||
// layout effect, and child layout effects fire before parent layout effects.
|
||||
pinToBottomByIndexRef.current = virtualizerOption
|
||||
? () => {
|
||||
const virtualizer = getVirtualizer();
|
||||
const lastIndex = virtualizerOption.itemCount - 1;
|
||||
if (!virtualizer || lastIndex < 0) return false;
|
||||
virtualizer.scrollToIndex(lastIndex, { align: "end" });
|
||||
return true;
|
||||
}
|
||||
: () => false;
|
||||
// Abandon any in-flight convergence on channel switch so a stale loop can't
|
||||
// hijack the new channel's scroll position.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: cancel on channel switch only
|
||||
React.useEffect(() => cancelConvergence, [channelId, cancelConvergence]);
|
||||
|
||||
// The unread pill is a transient, per-open affordance: dismiss it once the
|
||||
// user acts on it (jumps to the oldest unread) or catches up by reaching the
|
||||
// bottom of the timeline. Reset when the channel changes so a freshly opened
|
||||
@@ -308,13 +435,14 @@ const MessageTimelineBase = React.forwardRef<
|
||||
const handleJumpToOldestUnread = React.useCallback(() => {
|
||||
setIsUnreadPillDismissed(true);
|
||||
if (firstUnreadMessageId) {
|
||||
scrollToMessage(firstUnreadMessageId);
|
||||
jumpToMessage(firstUnreadMessageId);
|
||||
}
|
||||
}, [firstUnreadMessageId, scrollToMessage]);
|
||||
}, [firstUnreadMessageId, jumpToMessage]);
|
||||
|
||||
// Scroll to the active search match when it changes. `scrollToMessage`
|
||||
// updates the scroll anchor, so the post-commit restore won't yank the
|
||||
// view back off the match.
|
||||
// Scroll to the active search match when it changes. `jumpToMessage` updates
|
||||
// the scroll anchor (so the post-commit restore won't yank the view back off
|
||||
// the match) and, when virtualized, converges on the target through the index
|
||||
// model — the row may be windowed out of the DOM.
|
||||
const prevSearchActiveRef = React.useRef<string | null>(null);
|
||||
React.useEffect(() => {
|
||||
if (showTimelineSkeleton) return;
|
||||
@@ -326,8 +454,20 @@ const MessageTimelineBase = React.forwardRef<
|
||||
return;
|
||||
}
|
||||
prevSearchActiveRef.current = searchActiveMessageId;
|
||||
scrollToMessage(searchActiveMessageId, { behavior: "smooth" });
|
||||
}, [scrollToMessage, searchActiveMessageId, showTimelineSkeleton]);
|
||||
jumpToMessage(searchActiveMessageId, { behavior: "smooth" });
|
||||
}, [jumpToMessage, searchActiveMessageId, showTimelineSkeleton]);
|
||||
|
||||
useLoadOlderOnScroll({
|
||||
fetchOlder,
|
||||
hasOlderMessages,
|
||||
isLoading: showTimelineSkeleton,
|
||||
restoreScrollPosition,
|
||||
setLoadOlderRestoreInFlight,
|
||||
getAnchorIsAtBottom,
|
||||
scrollContainerRef,
|
||||
sentinelRef: topSentinelRef,
|
||||
virtualizer: virtualizerOption,
|
||||
});
|
||||
|
||||
const timelineSkeletonRows = useTimelineSkeletonRows({
|
||||
channelId,
|
||||
@@ -338,6 +478,18 @@ const MessageTimelineBase = React.forwardRef<
|
||||
return (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{/* Non-scrolling overlay anchored to the outer (non-scrolling) box: the
|
||||
floating active-day header portals in here, so it pins to a fixed
|
||||
offset and cannot drift as older history prepends. Sits below the
|
||||
unread pill / fetch spinner (z-20) in the stack. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-0 z-[6] flex translate-y-3 justify-center px-4",
|
||||
channelChrome.top,
|
||||
)}
|
||||
ref={activeDayHeaderRef}
|
||||
/>
|
||||
{showUnreadPill ? (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -390,6 +542,13 @@ const MessageTimelineBase = React.forwardRef<
|
||||
>
|
||||
<div ref={topSentinelRef} aria-hidden className="h-px" />
|
||||
|
||||
{/* Fixed-height slot: an always-mounted height keeps the virtual
|
||||
spacer's offset stable across the load-older fetch toggle, so
|
||||
`scrollMargin` doesn't shift mid-fetch and yank the restore. The
|
||||
visible fetch spinner lives in the absolute overlay above, which
|
||||
does not occupy inline flow. */}
|
||||
<div aria-hidden className="h-8" />
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-[18rem] min-w-0 flex-col gap-2",
|
||||
@@ -534,6 +693,7 @@ const MessageTimelineBase = React.forwardRef<
|
||||
data-render-pending={isRenderPending ? "true" : undefined}
|
||||
>
|
||||
<TimelineMessageList
|
||||
key={scrollContainerDomKey}
|
||||
agentPubkeys={agentPubkeys}
|
||||
channelId={channelId}
|
||||
channelName={channelName}
|
||||
@@ -564,6 +724,9 @@ const MessageTimelineBase = React.forwardRef<
|
||||
searchQuery={searchQuery}
|
||||
threadUnreadCounts={threadUnreadCounts}
|
||||
unfollowThreadById={unfollowThreadById}
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
headerOverlayRef={activeDayHeaderRef}
|
||||
onVirtualizer={handleVirtualizer}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { formatDayHeading } from "@/features/messages/lib/dateFormatters";
|
||||
import {
|
||||
formatDayHeading,
|
||||
isSameDay,
|
||||
startOfLocalDaySeconds,
|
||||
} from "@/features/messages/lib/dateFormatters";
|
||||
buildTimelineItems,
|
||||
getTimelineItemKey,
|
||||
resolveActiveDayTimestamp,
|
||||
type TimelineItem,
|
||||
} from "@/features/messages/lib/timelineItems";
|
||||
import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel";
|
||||
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
|
||||
import {
|
||||
buildMainTimelineEntries,
|
||||
shouldRenderUnreadDivider,
|
||||
} from "@/features/messages/lib/threadPanel";
|
||||
import {
|
||||
buildVideoReviewCommentsForRoot,
|
||||
buildVideoReviewCommentsByRootId,
|
||||
buildVideoReviewContextForMessage,
|
||||
hasVideoAttachment,
|
||||
} from "@/features/messages/lib/videoReviewContext";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import type { ChannelType } from "@/shared/api/types";
|
||||
import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import {
|
||||
type ListVirtualizer,
|
||||
VirtualizedList,
|
||||
} from "@/shared/ui/VirtualizedList";
|
||||
import { DayDivider } from "./DayDivider";
|
||||
import { ActiveDayHeader } from "./ActiveDayHeader";
|
||||
import { MessageRow } from "./MessageRow";
|
||||
import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow";
|
||||
import { SystemMessageRow } from "./SystemMessageRow";
|
||||
@@ -38,7 +42,9 @@ type TimelineMessageListProps = {
|
||||
isFollowingThreadById?: (rootId: string) => boolean;
|
||||
isMessageUnreadById?: (messageId: string) => boolean;
|
||||
messageFooters?: Record<string, React.ReactNode>;
|
||||
mainEntries?: ReturnType<typeof buildMainTimelineEntries>;
|
||||
/** Hoisted main-timeline entries (computed once in ChannelPane). Falls back
|
||||
* to deriving them here when omitted (e.g. the deferred-render pass). */
|
||||
mainEntries?: MainTimelineEntry[];
|
||||
messages: TimelineMessage[];
|
||||
onDelete?: (message: TimelineMessage) => void;
|
||||
onEdit?: (message: TimelineMessage) => void;
|
||||
@@ -70,118 +76,15 @@ type TimelineMessageListProps = {
|
||||
searchQuery?: string;
|
||||
/** Per-thread unread counts keyed by thread root id. */
|
||||
threadUnreadCounts?: ReadonlyMap<string, number>;
|
||||
/** Caller-owned scroll container the virtualizer measures and scrolls. */
|
||||
scrollContainerRef: React.RefObject<HTMLElement | null>;
|
||||
/** Non-scrolling overlay container the floating active-day header portals
|
||||
* into — sits OUTSIDE the scroll container so the header cannot drift. */
|
||||
headerOverlayRef: React.RefObject<HTMLElement | null>;
|
||||
/** Receives the virtualizer instance for index-model scroll paths. */
|
||||
onVirtualizer?: (virtualizer: ListVirtualizer) => void;
|
||||
};
|
||||
|
||||
type TimelineDayRow = {
|
||||
key: string;
|
||||
label: string;
|
||||
type: "day";
|
||||
};
|
||||
|
||||
type TimelineUnreadRow = {
|
||||
key: string;
|
||||
type: "unread";
|
||||
};
|
||||
|
||||
type TimelineMessageRowModel = {
|
||||
key: string;
|
||||
message: TimelineMessage;
|
||||
summary: ReturnType<typeof buildMainTimelineEntries>[number]["summary"];
|
||||
type: "message";
|
||||
};
|
||||
|
||||
type TimelineRenderRow =
|
||||
| TimelineDayRow
|
||||
| TimelineUnreadRow
|
||||
| TimelineMessageRowModel;
|
||||
|
||||
type TimelineNonDayRow = TimelineUnreadRow | TimelineMessageRowModel;
|
||||
|
||||
type TimelineDayGroup = {
|
||||
key: string;
|
||||
label: string;
|
||||
rows: TimelineNonDayRow[];
|
||||
};
|
||||
|
||||
function buildTimelineRenderRows({
|
||||
entries,
|
||||
firstUnreadMessageId,
|
||||
messages,
|
||||
}: {
|
||||
entries?: ReturnType<typeof buildMainTimelineEntries>;
|
||||
firstUnreadMessageId: string | null;
|
||||
messages: TimelineMessage[];
|
||||
}): TimelineRenderRow[] {
|
||||
entries ??= buildMainTimelineEntries(messages);
|
||||
const rows: TimelineRenderRow[] = [];
|
||||
let previousMessage: TimelineMessage | null = null;
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const { message, summary } = entries[i];
|
||||
const messageRenderKey = message.renderKey ?? message.id;
|
||||
|
||||
if (
|
||||
!previousMessage ||
|
||||
!isSameDay(previousMessage.createdAt, message.createdAt)
|
||||
) {
|
||||
rows.push({
|
||||
key: `day-${startOfLocalDaySeconds(message.createdAt)}`,
|
||||
label: formatDayHeading(message.createdAt),
|
||||
type: "day",
|
||||
});
|
||||
}
|
||||
|
||||
// The unread "New" divider only marks a read/unread boundary when there is
|
||||
// a message above the first unread. When the first unread is the first
|
||||
// rendered top-level entry (fresh/never-read channel), there is nothing
|
||||
// above to separate from, so it is suppressed.
|
||||
if (shouldRenderUnreadDivider(i, message.id, firstUnreadMessageId)) {
|
||||
rows.push({ key: `unread:${messageRenderKey}`, type: "unread" });
|
||||
}
|
||||
|
||||
rows.push({
|
||||
key: `msg:${messageRenderKey}`,
|
||||
message,
|
||||
summary,
|
||||
type: "message",
|
||||
});
|
||||
|
||||
previousMessage = message;
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function buildTimelineDayGroups(rows: TimelineRenderRow[]): TimelineDayGroup[] {
|
||||
const groups: TimelineDayGroup[] = [];
|
||||
let currentGroup: TimelineDayGroup | null = null;
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.type === "day") {
|
||||
currentGroup = {
|
||||
key: row.key,
|
||||
label: row.label,
|
||||
rows: [],
|
||||
};
|
||||
groups.push(currentGroup);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!currentGroup) {
|
||||
currentGroup = {
|
||||
key: "day-undated",
|
||||
label: "",
|
||||
rows: [],
|
||||
};
|
||||
groups.push(currentGroup);
|
||||
}
|
||||
|
||||
currentGroup.rows.push(row);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
agentPubkeys,
|
||||
channelId,
|
||||
@@ -210,169 +113,271 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
searchQuery,
|
||||
threadUnreadCounts,
|
||||
unfollowThreadById,
|
||||
scrollContainerRef,
|
||||
headerOverlayRef,
|
||||
onVirtualizer,
|
||||
}: TimelineMessageListProps) {
|
||||
const rows = React.useMemo(
|
||||
() =>
|
||||
buildTimelineRenderRows({
|
||||
entries: mainEntries,
|
||||
firstUnreadMessageId,
|
||||
messages,
|
||||
}),
|
||||
[firstUnreadMessageId, mainEntries, messages],
|
||||
const entries = React.useMemo(
|
||||
() => mainEntries ?? buildMainTimelineEntries(messages),
|
||||
[mainEntries, messages],
|
||||
);
|
||||
const dayGroups = React.useMemo(() => buildTimelineDayGroups(rows), [rows]);
|
||||
const reviewCommentsByRootId = React.useMemo(
|
||||
() =>
|
||||
messages.some(hasVideoAttachment)
|
||||
? buildVideoReviewCommentsByRootId(messages)
|
||||
: new Map<string, TimelineMessage[]>(),
|
||||
[messages],
|
||||
);
|
||||
// Contexts are memoized per message id so MessageRow/Markdown memo
|
||||
// comparisons hold across unrelated timeline re-renders (typing
|
||||
// indicators, presence updates) — a fresh context object per render would
|
||||
// defeat the memo and re-render every video message on every pass.
|
||||
const videoReviewContextById = React.useMemo(() => {
|
||||
const contexts = new Map<
|
||||
string,
|
||||
NonNullable<ReturnType<typeof buildVideoReviewContextForMessage>>
|
||||
>();
|
||||
for (const message of messages) {
|
||||
const comments = reviewCommentsByRootId.get(message.id) ?? [];
|
||||
const context = buildVideoReviewContextForMessage({
|
||||
channelId,
|
||||
channelName,
|
||||
channelType,
|
||||
comments,
|
||||
isSendingVideoReviewComment,
|
||||
message,
|
||||
onSendVideoReviewComment,
|
||||
onToggleReaction,
|
||||
profiles,
|
||||
});
|
||||
if (context) {
|
||||
contexts.set(message.id, context);
|
||||
}
|
||||
}
|
||||
return contexts;
|
||||
}, [
|
||||
channelId,
|
||||
channelName,
|
||||
channelType,
|
||||
isSendingVideoReviewComment,
|
||||
messages,
|
||||
onSendVideoReviewComment,
|
||||
onToggleReaction,
|
||||
profiles,
|
||||
reviewCommentsByRootId,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0">
|
||||
{dayGroups.map((group) => (
|
||||
<section
|
||||
className="relative flex flex-col gap-2 before:absolute before:inset-x-0 before:top-4 before:h-px before:bg-border/35 before:content-['']"
|
||||
data-day-label={group.label}
|
||||
data-testid="message-timeline-day-group"
|
||||
key={group.key}
|
||||
>
|
||||
{group.label ? <DayDivider label={group.label} /> : null}
|
||||
{group.rows.map((row) => (
|
||||
<TimelineRenderRowView
|
||||
agentPubkeys={agentPubkeys}
|
||||
allMessages={
|
||||
row.type === "message" && hasVideoAttachment(row.message)
|
||||
? messages
|
||||
: undefined
|
||||
}
|
||||
channelId={channelId}
|
||||
channelName={channelName}
|
||||
channelType={channelType}
|
||||
// The flattened item stream and its messageId -> itemIndex map are produced
|
||||
// together from ONE memo, keyed on the entries and the unread boundary (the
|
||||
// unread divider is its own item, so it shifts indices). A separate memo with
|
||||
// diverging deps would let the map go stale and scroll deep-links to the wrong
|
||||
// row — the exact failure virtualization risks.
|
||||
const itemsResult = React.useMemo(
|
||||
() => buildTimelineItems(entries, firstUnreadMessageId),
|
||||
[entries, firstUnreadMessageId],
|
||||
);
|
||||
|
||||
const renderItem = React.useCallback(
|
||||
(item: TimelineItem) => {
|
||||
switch (item.kind) {
|
||||
case "day-divider":
|
||||
// Heading is resolved at render time (not baked into the item) so
|
||||
// "Today"/"Yesterday" track the wall clock, not build time.
|
||||
return <DayDivider label={formatDayHeading(item.headingTimestamp)} />;
|
||||
case "unread-divider":
|
||||
return <UnreadDivider />;
|
||||
case "system":
|
||||
return (
|
||||
<SystemRow
|
||||
currentPubkey={currentPubkey}
|
||||
followThreadById={followThreadById}
|
||||
highlightedMessageId={highlightedMessageId}
|
||||
isFollowingThreadById={isFollowingThreadById}
|
||||
isMessageUnreadById={isMessageUnreadById}
|
||||
isSendingVideoReviewComment={isSendingVideoReviewComment}
|
||||
key={row.key}
|
||||
messageFooters={messageFooters}
|
||||
onDelete={onDelete}
|
||||
onEdit={onEdit}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onMarkRead={onMarkRead}
|
||||
onReply={onReply}
|
||||
onSendVideoReviewComment={onSendVideoReviewComment}
|
||||
entry={item.entry}
|
||||
footer={messageFooters?.[item.entry.message.id] ?? null}
|
||||
onToggleReaction={onToggleReaction}
|
||||
profiles={profiles}
|
||||
/>
|
||||
);
|
||||
case "message":
|
||||
return (
|
||||
<MessageRowItem
|
||||
agentPubkeys={agentPubkeys}
|
||||
channelId={channelId}
|
||||
currentPubkey={currentPubkey}
|
||||
entry={item.entry}
|
||||
followThreadById={followThreadById}
|
||||
footer={messageFooters?.[item.entry.message.id] ?? null}
|
||||
highlightedMessageId={highlightedMessageId}
|
||||
isFollowingThreadById={isFollowingThreadById}
|
||||
isUnread={isMessageUnreadById?.(item.entry.message.id)}
|
||||
onDelete={onDelete}
|
||||
onEdit={onEdit}
|
||||
onMarkRead={onMarkRead}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onReply={onReply}
|
||||
onToggleReaction={onToggleReaction}
|
||||
profiles={profiles}
|
||||
row={row}
|
||||
searchActiveMessageId={searchActiveMessageId}
|
||||
searchMatchingMessageIds={searchMatchingMessageIds}
|
||||
searchQuery={searchQuery}
|
||||
threadUnreadCounts={threadUnreadCounts}
|
||||
unfollowThreadById={unfollowThreadById}
|
||||
videoReviewContext={videoReviewContextById.get(
|
||||
item.entry.message.id,
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
[
|
||||
agentPubkeys,
|
||||
channelId,
|
||||
currentPubkey,
|
||||
followThreadById,
|
||||
highlightedMessageId,
|
||||
isFollowingThreadById,
|
||||
isMessageUnreadById,
|
||||
messageFooters,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onMarkRead,
|
||||
onMarkUnread,
|
||||
onReply,
|
||||
onToggleReaction,
|
||||
profiles,
|
||||
searchActiveMessageId,
|
||||
searchMatchingMessageIds,
|
||||
searchQuery,
|
||||
threadUnreadCounts,
|
||||
unfollowThreadById,
|
||||
videoReviewContextById,
|
||||
],
|
||||
);
|
||||
|
||||
// The floating active-day header is portaled by VirtualizedList into the
|
||||
// non-scrolling overlay container (outside the scroll element) and is
|
||||
// re-invoked with the topmost visible row index on every scroll. We resolve
|
||||
// that index back to the day it belongs to so the label tracks the viewport
|
||||
// even after that day's in-stream divider has been windowed out.
|
||||
const renderStickyHeader = React.useCallback(
|
||||
(topVisibleIndex: number | null) => {
|
||||
const headingTimestamp = resolveActiveDayTimestamp(
|
||||
itemsResult.items,
|
||||
topVisibleIndex,
|
||||
);
|
||||
if (headingTimestamp === null) {
|
||||
return null;
|
||||
}
|
||||
return <ActiveDayHeader label={formatDayHeading(headingTimestamp)} />;
|
||||
},
|
||||
[itemsResult.items],
|
||||
);
|
||||
|
||||
return (
|
||||
<VirtualizedList
|
||||
getItemKey={getTimelineItemKey}
|
||||
headerOverlayRef={headerOverlayRef}
|
||||
innerClassName="flex flex-col"
|
||||
items={itemsResult.items}
|
||||
onVirtualizer={onVirtualizer}
|
||||
renderItem={renderItem}
|
||||
scrollRef={scrollContainerRef}
|
||||
stickyHeader={renderStickyHeader}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
type TimelineRenderRowViewProps = Omit<
|
||||
function SystemRow({
|
||||
currentPubkey,
|
||||
entry,
|
||||
footer,
|
||||
onToggleReaction,
|
||||
profiles,
|
||||
}: {
|
||||
currentPubkey?: string;
|
||||
entry: MainTimelineEntry;
|
||||
footer: React.ReactNode;
|
||||
onToggleReaction?: TimelineMessageListProps["onToggleReaction"];
|
||||
profiles?: UserProfileLookup;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 pb-2.5">
|
||||
<SystemMessageRow
|
||||
message={entry.message}
|
||||
currentPubkey={currentPubkey}
|
||||
onToggleReaction={onToggleReaction}
|
||||
profiles={profiles}
|
||||
/>
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type MessageRowItemProps = Pick<
|
||||
TimelineMessageListProps,
|
||||
"firstUnreadMessageId" | "messages" | "personaLookup"
|
||||
| "agentPubkeys"
|
||||
| "channelId"
|
||||
| "currentPubkey"
|
||||
| "followThreadById"
|
||||
| "highlightedMessageId"
|
||||
| "isFollowingThreadById"
|
||||
| "onDelete"
|
||||
| "onEdit"
|
||||
| "onMarkUnread"
|
||||
| "onMarkRead"
|
||||
| "onReply"
|
||||
| "onToggleReaction"
|
||||
| "profiles"
|
||||
| "searchActiveMessageId"
|
||||
| "searchMatchingMessageIds"
|
||||
| "searchQuery"
|
||||
| "threadUnreadCounts"
|
||||
| "unfollowThreadById"
|
||||
> & {
|
||||
allMessages?: TimelineMessage[];
|
||||
row: TimelineRenderRow;
|
||||
entry: MainTimelineEntry;
|
||||
footer: React.ReactNode;
|
||||
isUnread?: boolean;
|
||||
videoReviewContext: ReturnType<typeof buildVideoReviewContextForMessage>;
|
||||
};
|
||||
|
||||
const TimelineRenderRowView = React.memo(function TimelineRenderRowView({
|
||||
function MessageRowItem({
|
||||
agentPubkeys,
|
||||
allMessages,
|
||||
channelId,
|
||||
channelName,
|
||||
channelType,
|
||||
currentPubkey,
|
||||
entry,
|
||||
followThreadById,
|
||||
highlightedMessageId = null,
|
||||
footer,
|
||||
highlightedMessageId,
|
||||
isFollowingThreadById,
|
||||
isMessageUnreadById,
|
||||
isSendingVideoReviewComment = false,
|
||||
messageFooters,
|
||||
isUnread,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onMarkUnread,
|
||||
onMarkRead,
|
||||
onReply,
|
||||
onSendVideoReviewComment,
|
||||
onToggleReaction,
|
||||
profiles,
|
||||
searchActiveMessageId = null,
|
||||
searchActiveMessageId,
|
||||
searchMatchingMessageIds,
|
||||
searchQuery,
|
||||
row,
|
||||
threadUnreadCounts,
|
||||
unfollowThreadById,
|
||||
}: TimelineRenderRowViewProps) {
|
||||
const messageForContext = row.type === "message" ? row.message : null;
|
||||
const videoReviewContext = React.useMemo(() => {
|
||||
if (!allMessages || !messageForContext) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return buildVideoReviewContextForMessage({
|
||||
channelId,
|
||||
channelName,
|
||||
channelType,
|
||||
comments: buildVideoReviewCommentsForRoot(
|
||||
allMessages,
|
||||
messageForContext.id,
|
||||
),
|
||||
isSendingVideoReviewComment,
|
||||
message: messageForContext,
|
||||
onSendVideoReviewComment,
|
||||
onToggleReaction,
|
||||
profiles,
|
||||
});
|
||||
}, [
|
||||
allMessages,
|
||||
channelId,
|
||||
channelName,
|
||||
channelType,
|
||||
isSendingVideoReviewComment,
|
||||
messageForContext,
|
||||
onSendVideoReviewComment,
|
||||
onToggleReaction,
|
||||
profiles,
|
||||
]);
|
||||
|
||||
if (row.type === "day") {
|
||||
return <DayDivider label={row.label} />;
|
||||
}
|
||||
|
||||
if (row.type === "unread") {
|
||||
return <UnreadDivider />;
|
||||
}
|
||||
|
||||
const { message, summary } = row;
|
||||
|
||||
if (message.kind === KIND_SYSTEM_MESSAGE) {
|
||||
const footer = messageFooters?.[message.id] ?? null;
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<SystemMessageRow
|
||||
message={message}
|
||||
currentPubkey={currentPubkey}
|
||||
onToggleReaction={onToggleReaction}
|
||||
profiles={profiles}
|
||||
/>
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
videoReviewContext,
|
||||
}: MessageRowItemProps) {
|
||||
const { message, summary } = entry;
|
||||
const canDelete =
|
||||
onDelete && currentPubkey && message.pubkey === currentPubkey
|
||||
? onDelete
|
||||
: undefined;
|
||||
const canEdit =
|
||||
onEdit && currentPubkey && message.pubkey === currentPubkey
|
||||
? onEdit
|
||||
: undefined;
|
||||
|
||||
if (summary && onReply) {
|
||||
const footer = messageFooters?.[message.id] ?? null;
|
||||
const isHighlighted = message.id === highlightedMessageId;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group/message relative mx-1 flex flex-col gap-0 rounded-2xl px-0 py-1 transition-colors hover:bg-muted/50 focus-within:bg-muted/50",
|
||||
"group/message relative mx-1 flex flex-col gap-0 rounded-2xl px-0 py-1 pb-2.5 transition-colors hover:bg-muted/50 focus-within:bg-muted/50",
|
||||
isHighlighted &&
|
||||
"-mx-4 px-4 before:absolute before:-inset-y-1.5 before:inset-x-0 before:animate-[route-target-highlight-fade_2s_ease-out_forwards] before:bg-primary/10 before:content-[''] motion-reduce:before:animate-none sm:-mx-6 sm:px-6",
|
||||
)}
|
||||
@@ -387,23 +392,15 @@ const TimelineRenderRowView = React.memo(function TimelineRenderRowView({
|
||||
? isFollowingThreadById(message.id)
|
||||
: undefined
|
||||
}
|
||||
isUnread={isMessageUnreadById?.(message.id)}
|
||||
isUnread={isUnread}
|
||||
message={message}
|
||||
onDelete={
|
||||
onDelete && currentPubkey && message.pubkey === currentPubkey
|
||||
? onDelete
|
||||
: undefined
|
||||
}
|
||||
onEdit={
|
||||
onEdit && currentPubkey && message.pubkey === currentPubkey
|
||||
? onEdit
|
||||
: undefined
|
||||
}
|
||||
onDelete={canDelete}
|
||||
onEdit={canEdit}
|
||||
onFollowThread={
|
||||
followThreadById ? () => followThreadById(message.id) : undefined
|
||||
}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onMarkRead={onMarkRead}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onToggleReaction={onToggleReaction}
|
||||
onReply={onReply}
|
||||
onUnfollowThread={
|
||||
@@ -430,28 +427,19 @@ const TimelineRenderRowView = React.memo(function TimelineRenderRowView({
|
||||
|
||||
const isSearchMatch = searchMatchingMessageIds?.has(message.id) ?? false;
|
||||
const isSearchActive = message.id === searchActiveMessageId;
|
||||
const footer = messageFooters?.[message.id] ?? null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex flex-col gap-1 pb-2.5">
|
||||
<MessageRow
|
||||
agentPubkeys={agentPubkeys}
|
||||
channelId={channelId}
|
||||
highlighted={message.id === highlightedMessageId || isSearchActive}
|
||||
isUnread={isMessageUnreadById?.(message.id)}
|
||||
isUnread={isUnread}
|
||||
message={message}
|
||||
onDelete={
|
||||
onDelete && currentPubkey && message.pubkey === currentPubkey
|
||||
? onDelete
|
||||
: undefined
|
||||
}
|
||||
onEdit={
|
||||
onEdit && currentPubkey && message.pubkey === currentPubkey
|
||||
? onEdit
|
||||
: undefined
|
||||
}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onDelete={canDelete}
|
||||
onEdit={canEdit}
|
||||
onMarkRead={onMarkRead}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onToggleReaction={onToggleReaction}
|
||||
onReply={onReply}
|
||||
profiles={profiles}
|
||||
@@ -462,4 +450,4 @@ const TimelineRenderRowView = React.memo(function TimelineRenderRowView({
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,9 +20,6 @@ type UseAnchoredScrollOptions = {
|
||||
/** Inner content element — must wrap every renderable row, including the
|
||||
* sentinel and bottom anchor. Used to schedule layout work on resize. */
|
||||
contentRef: React.RefObject<HTMLDivElement | null>;
|
||||
/** Small zero-height element near the very top of the content. When it
|
||||
* intersects the viewport (with some rootMargin) we trigger fetchOlder. */
|
||||
sentinelRef: React.RefObject<HTMLDivElement | null>;
|
||||
/** Resets when changed; lets us drop anchor + scroll state across channels. */
|
||||
channelId?: string | null;
|
||||
/** Suppresses initial scroll-to-bottom while a skeleton is showing. */
|
||||
@@ -30,19 +27,28 @@ type UseAnchoredScrollOptions = {
|
||||
/** Source of truth for the rendered list. Used to detect new-at-bottom
|
||||
* arrivals and to seed/refresh the anchor pre-render. */
|
||||
messages: TimelineMessage[];
|
||||
/** Optional callback to fetch older history. The hook handles intersection,
|
||||
* debouncing, and post-prepend scroll restoration via the anchor. */
|
||||
fetchOlder?: () => Promise<void>;
|
||||
hasOlderMessages?: boolean;
|
||||
/** True while an older-history fetch is in flight. Threaded in as a
|
||||
* restoration re-run trigger so the anchor reasserts itself around the
|
||||
* prepend on the fetch-state toggle, not only on the `messages` change. */
|
||||
isFetchingOlder?: boolean;
|
||||
/** Re-runs restoration when surrounding chrome changes size without changing messages. */
|
||||
layoutShiftKey?: string | number | null;
|
||||
|
||||
/** When set, scroll to and highlight this message on mount and on change. */
|
||||
targetMessageId?: string | null;
|
||||
onTargetReached?: (messageId: string) => void;
|
||||
/** Optional convergence fallback for a `targetMessageId` whose row is not in
|
||||
* the DOM (windowed out of a virtualized list). When the DOM lookup fails,
|
||||
* the hook delegates to this instead of waiting for a later commit that may
|
||||
* never render the row. The consumer drives the virtualizer to the target,
|
||||
* warming up if it's still being fetched, and fires `onTargetReached` itself
|
||||
* on settle. Returns `true` once it owns the target (the hook marks it
|
||||
* handled, no further dispatch). Absent (thread panel) → DOM-only retry. */
|
||||
convergeToTarget?: (messageId: string) => boolean;
|
||||
/** Optional: pin the initial mount view to the last row through the
|
||||
* virtualizer's own `scrollToIndex(lastIndex, {align:"end"})`, rather than a
|
||||
* raw `scrollTo(scrollHeight)`. At mount the virtualizer's rows are still
|
||||
* estimate-sized, so `scrollHeight` is short of the true total and a raw pin
|
||||
* lands above the real bottom (and re-pins chase a moving floor). Driving the
|
||||
* last index through the library lets it own landing that row at the bottom
|
||||
* across its own measurement. Returns `true` once it issued (caller keeps the
|
||||
* at-bottom anchor); `false` when no virtualizer is available (thread panel),
|
||||
* so the hook falls back to the raw bottom pin. */
|
||||
pinToBottomByIndex?: () => boolean;
|
||||
};
|
||||
|
||||
type UseAnchoredScrollResult = {
|
||||
@@ -66,6 +72,20 @@ type UseAnchoredScrollResult = {
|
||||
messageId: string,
|
||||
options?: { highlight?: boolean; behavior?: ScrollBehavior },
|
||||
) => boolean;
|
||||
/** Single-writer scroll restore for the load-older index path. Sets
|
||||
* `scrollTop` directly (no scroll event fires for a programmatic write),
|
||||
* then re-seats the anchor + at-bottom bookkeeping so the next passive
|
||||
* restore and `isAtBottom` read agree with where we put the scroll. */
|
||||
restoreScrollPosition: (scrollTop: number) => void;
|
||||
/** Brackets the load-older index restore's scroll ownership. While `true`,
|
||||
* the ResizeObserver cedes — the index path is the sole `scrollTop` writer
|
||||
* across the prepend, mirroring the `convergingTargetIdRef` cede. */
|
||||
setLoadOlderRestoreInFlight: (inFlight: boolean) => void;
|
||||
/** Live read of whether the anchor is stuck-to-bottom. The load-older index
|
||||
* restore loop polls this each frame so a mid-prepend jump-to-bottom
|
||||
* (abandon) re-aims it at the bottom instead of the captured mid-history
|
||||
* anchor. */
|
||||
getAnchorIsAtBottom: () => boolean;
|
||||
};
|
||||
|
||||
function isAtBottomNow(container: HTMLDivElement) {
|
||||
@@ -190,7 +210,19 @@ function restoreAnchorToMessage(
|
||||
}
|
||||
|
||||
if (!anchorEl) {
|
||||
// Anchor message and all subsequent rendered messages are gone.
|
||||
// The anchor row is not in the DOM. Two distinct causes — only one means
|
||||
// "go to bottom":
|
||||
// 1. Deleted: the anchor and all newer rows are gone from `messages`
|
||||
// (e.g. message deletion). Nothing newer to anchor to → pin to bottom.
|
||||
// 2. Windowed out: the virtualizer dropped the anchor row from its
|
||||
// rendered window because the user scrolled it off-screen (it is still
|
||||
// present in `messages`). Pinning to bottom here would yank the view
|
||||
// back down mid-scroll — the wheel-up-snaps-back regression. Leave the
|
||||
// scroll where the user put it and keep the anchor; the virtualizer
|
||||
// will render the row again when it re-enters the window.
|
||||
if (messages.some((m) => m.id === anchor.messageId)) {
|
||||
return anchor;
|
||||
}
|
||||
container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
|
||||
return { kind: "at-bottom" };
|
||||
}
|
||||
@@ -207,16 +239,14 @@ function restoreAnchorToMessage(
|
||||
export function useAnchoredScroll({
|
||||
scrollContainerRef,
|
||||
contentRef,
|
||||
sentinelRef,
|
||||
channelId,
|
||||
isLoading,
|
||||
messages,
|
||||
fetchOlder,
|
||||
hasOlderMessages = false,
|
||||
isFetchingOlder = false,
|
||||
layoutShiftKey = null,
|
||||
|
||||
targetMessageId = null,
|
||||
onTargetReached,
|
||||
convergeToTarget,
|
||||
pinToBottomByIndex,
|
||||
}: UseAnchoredScrollOptions): UseAnchoredScrollResult {
|
||||
// Anchor lives in a ref because it must survive renders and is updated
|
||||
// both on scroll (commit-time read) and in the layout effect (post-render
|
||||
@@ -234,16 +264,54 @@ export function useAnchoredScroll({
|
||||
>(null);
|
||||
|
||||
const hasInitializedRef = React.useRef(false);
|
||||
// Mirror the convergence fallback into a ref so the target effects read the
|
||||
// live callback without re-subscribing on every consumer render.
|
||||
const convergeToTargetRef = React.useRef(convergeToTarget);
|
||||
convergeToTargetRef.current = convergeToTarget;
|
||||
// Mirror the index-driven mount pin into a ref for the same reason — the
|
||||
// layout effect reads the live callback without re-subscribing on every
|
||||
// consumer render. Absent (thread panel, no virtualizer) → falls back to the
|
||||
// raw bottom pin.
|
||||
const pinToBottomByIndexRef = React.useRef(pinToBottomByIndex);
|
||||
pinToBottomByIndexRef.current = pinToBottomByIndex;
|
||||
const prevLastMessageIdRef = React.useRef<string | undefined>(undefined);
|
||||
// Tracks the FRONT (oldest) rendered id so the restore effect can detect a
|
||||
// load-older prepend (front changed, tail unchanged) and cede it to the
|
||||
// index path — see IMPORTANT #1 in the restore effect below.
|
||||
const prevFirstMessageIdRef = React.useRef<string | undefined>(undefined);
|
||||
const prevMessageCountRef = React.useRef(0);
|
||||
const fetchingOlderRef = React.useRef(false);
|
||||
const handledTargetIdRef = React.useRef<string | null>(null);
|
||||
// Set while a convergence loop owns the scroll position (jump-to-message into
|
||||
// windowed-out history). The library's reconcile loop is the sole writer
|
||||
// during convergence, so the anchored restore below must cede — otherwise its
|
||||
// at-bottom `scrollTo` would yank the view back as the target row splices in,
|
||||
// the same two-writer contention the prepend bail prevents. Cleared when the
|
||||
// target settles (consumer clears the route param → `targetMessageId` null).
|
||||
const convergingTargetIdRef = React.useRef<string | null>(null);
|
||||
// Set while `useLoadOlderOnScroll`'s index-restore loop owns scroll across a
|
||||
// load-older prepend. That loop drives the virtualizer to re-aim the anchored
|
||||
// row as the prepended rows measure; the ResizeObserver must cede for the
|
||||
// same reason it cedes during convergence — otherwise the prepended rows
|
||||
// growing `scrollHeight` fire the observer with the (now windowed-out) anchor,
|
||||
// its all-gone fallback pins to the floor, and stomps the index restore's
|
||||
// correct offset. The layout effect already cedes the prepend (isPrepend
|
||||
// bail); this is the matching cede for the non-React-driven observer writer.
|
||||
const loadOlderRestoreInFlightRef = React.useRef(false);
|
||||
const highlightTimeoutRef = React.useRef<number | null>(null);
|
||||
// Tracks a pending rAF queued by pinToBottomOnMount so it can be cancelled
|
||||
// on channel switch (the channelId reset effect clears it).
|
||||
const mountPinRafIdRef = React.useRef<number | null>(null);
|
||||
// One-shot: the consumer calls `scrollToBottomOnNextUpdate()` right before
|
||||
// it sends a message (see ChannelPane). When the user's own message then
|
||||
// appends, we snap to bottom even if they had scrolled up to read history.
|
||||
// Consumed (and cleared) by the next append in the restoration effect.
|
||||
const forceBottomOnNextAppendRef = React.useRef(false);
|
||||
// True from the initial bottom pin until the virtualizer's row measurement
|
||||
// settles and the view reaches a true at-bottom. During this window `onScroll`
|
||||
// ignores the transient gap the settle opens (see the guard there). A `ref`,
|
||||
// not state — the guard runs on a native scroll event, outside React's render
|
||||
// cycle.
|
||||
const settlingRef = React.useRef(false);
|
||||
|
||||
// Reset everything when the channel changes — the layout effect that runs
|
||||
// immediately after this reset is responsible for either jumping to bottom
|
||||
@@ -256,14 +324,21 @@ export function useAnchoredScroll({
|
||||
setHighlightedMessageId(null);
|
||||
hasInitializedRef.current = false;
|
||||
prevLastMessageIdRef.current = undefined;
|
||||
prevFirstMessageIdRef.current = undefined;
|
||||
prevMessageCountRef.current = 0;
|
||||
fetchingOlderRef.current = false;
|
||||
handledTargetIdRef.current = null;
|
||||
convergingTargetIdRef.current = null;
|
||||
loadOlderRestoreInFlightRef.current = false;
|
||||
forceBottomOnNextAppendRef.current = false;
|
||||
settlingRef.current = false;
|
||||
if (highlightTimeoutRef.current !== null) {
|
||||
window.clearTimeout(highlightTimeoutRef.current);
|
||||
highlightTimeoutRef.current = null;
|
||||
}
|
||||
if (mountPinRafIdRef.current !== null) {
|
||||
cancelAnimationFrame(mountPinRafIdRef.current);
|
||||
mountPinRafIdRef.current = null;
|
||||
}
|
||||
}, [channelId]);
|
||||
|
||||
const scrollToBottomImperative = React.useCallback(
|
||||
@@ -271,6 +346,17 @@ export function useAnchoredScroll({
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
anchorRef.current = { kind: "at-bottom" };
|
||||
// A smooth (animated) jump-to-latest is not atomic: the browser scrolls
|
||||
// over several frames, and each intermediate `scroll` event would drive
|
||||
// `onScroll` → `computeAnchor`, which latches a mid-history message anchor
|
||||
// because the view is transiently NOT at the bottom. The ResizeObserver
|
||||
// then "restores" that stale anchor and strands the view mid-list. Arm
|
||||
// the same settle window the mount pin uses so `onScroll` holds the
|
||||
// at-bottom anchor until the animation lands a true at-bottom; the
|
||||
// observer's at-bottom re-pin then keeps the view glued as late row
|
||||
// measurement grows `scrollHeight`. (An instant "auto" scroll completes
|
||||
// before any scroll event, so it needs no guard.)
|
||||
if (behavior === "smooth") settlingRef.current = true;
|
||||
container.scrollTo({ top: container.scrollHeight, behavior });
|
||||
setIsAtBottom(true);
|
||||
setNewMessageCount(0);
|
||||
@@ -346,12 +432,77 @@ export function useAnchoredScroll({
|
||||
[scrollContainerRef],
|
||||
);
|
||||
|
||||
// Re-seat the anchor + at-bottom bookkeeping after a programmatic scrollTop
|
||||
// write. A programmatic write fires no scroll event, so `onScroll` won't run
|
||||
// to refresh `anchorRef`/`isAtBottom` — we run the same derivation here so
|
||||
// the next passive restore and at-bottom read agree with the new position.
|
||||
// We deliberately do NOT touch `newMessageCount`: a load-older restore keeps
|
||||
// the reader mid-history, so the unread-count affordance must be untouched.
|
||||
const syncAnchorAfterProgrammaticScroll = React.useCallback(
|
||||
(container: HTMLDivElement) => {
|
||||
anchorRef.current = computeAnchor(container);
|
||||
const atBottom = anchorRef.current.kind === "at-bottom";
|
||||
setIsAtBottom((prev) => (prev === atBottom ? prev : atBottom));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Single-writer restore for the load-older index path (IMPORTANT #2). The
|
||||
// index path resolves the exact target `scrollTop` off the virtualizer's
|
||||
// settled measurement cache (`getOffsetForIndex`), so a bare assignment is
|
||||
// correct on the first write — no rAF re-assert loop, no manager scroll-state
|
||||
// machine. Re-seating the anchor afterwards keeps this the sole owner.
|
||||
const restoreScrollPosition = React.useCallback(
|
||||
(scrollTop: number) => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
container.scrollTop = scrollTop;
|
||||
syncAnchorAfterProgrammaticScroll(container);
|
||||
},
|
||||
[scrollContainerRef, syncAnchorAfterProgrammaticScroll],
|
||||
);
|
||||
|
||||
// Let the load-older index path mark its scroll-ownership window so the
|
||||
// ResizeObserver cedes to it (see `loadOlderRestoreInFlightRef`).
|
||||
const setLoadOlderRestoreInFlight = React.useCallback((inFlight: boolean) => {
|
||||
loadOlderRestoreInFlightRef.current = inFlight;
|
||||
}, []);
|
||||
|
||||
// Live read of whether the anchor is stuck-to-bottom, for the load-older
|
||||
// restore loop. If the user jumps to bottom (abandon) WHILE that loop owns
|
||||
// scroll, the loop is re-aiming `scrollToOffset` at the captured mid-history
|
||||
// anchor and would land there, not at the bottom the user asked for — and the
|
||||
// ResizeObserver re-pin is ceded to the loop for the whole window, so nothing
|
||||
// chases the last rows to the true floor. The loop reads this each frame to
|
||||
// detect the abandon and re-aim to the bottom instead.
|
||||
const getAnchorIsAtBottom = React.useCallback(
|
||||
() => anchorRef.current.kind === "at-bottom",
|
||||
[],
|
||||
);
|
||||
|
||||
// Scroll handler: recompute anchor + bottom state from the current
|
||||
// scroll position. Cheap enough to run on every scroll event — a single
|
||||
// `getBoundingClientRect` walk plus rect reads.
|
||||
const onScroll = React.useCallback(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
// A virtualizer settle grows `scrollHeight` (rows below the fold measure a
|
||||
// frame or two after the initial bottom pin) and emits scroll events while
|
||||
// `scrollTop` holds at the old floor — opening a transient gap above the
|
||||
// true bottom. `computeAnchor` would read that gap as a deliberate
|
||||
// scroll-up and latch a message anchor, freezing the view short of the
|
||||
// bottom (the non-virtualized list had full height at pin time, so it never
|
||||
// produced this gap). While settling, keep the anchor at-bottom so the
|
||||
// resize observer's re-pin can finish chasing the floor; clear the window
|
||||
// once a scroll event reads a genuine at-bottom. Scoped to the initial
|
||||
// settle so post-settle user scroll-up re-evaluates the anchor normally.
|
||||
if (settlingRef.current) {
|
||||
if (isAtBottomNow(container)) {
|
||||
settlingRef.current = false;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
anchorRef.current = computeAnchor(container);
|
||||
const atBottom = anchorRef.current.kind === "at-bottom";
|
||||
setIsAtBottom((prev) => (prev === atBottom ? prev : atBottom));
|
||||
@@ -363,10 +514,12 @@ export function useAnchoredScroll({
|
||||
// ---------------------------------------------------------------------------
|
||||
// Anchor restoration: after every render, if the anchor was a message,
|
||||
// realign so that message sits at the same top-relative offset it had
|
||||
// before the render. This is the single mechanism for keeping scroll
|
||||
// stable across prepends, appends, image loads, embed expansions, etc.
|
||||
// before the render. This keeps scroll stable across appends, image loads,
|
||||
// and embed expansions. Load-older prepends are NOT handled here — they are
|
||||
// ceded to `useLoadOlderOnScroll`'s index anchor (see the prepend bail
|
||||
// below) so a single writer owns `scrollTop` on the prepend commit.
|
||||
// ---------------------------------------------------------------------------
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: `isFetchingOlder` and `layoutShiftKey` are intentional re-run triggers, not reads. They re-run restoration when fetch state or surrounding layout changes so the anchor reasserts itself; the correction is a no-op when nothing above the anchor moved.
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
@@ -381,6 +534,31 @@ export function useAnchoredScroll({
|
||||
// to the requested target message, or to the bottom by default.
|
||||
if (!hasInitializedRef.current) {
|
||||
if (isLoading) return;
|
||||
// Mount bottom pin. Prefer the virtualizer's own
|
||||
// `scrollToIndex(lastIndex, {align:"end"})` (via `pinToBottomByIndex`):
|
||||
// at mount the rows are estimate-sized, so a raw `scrollTo(scrollHeight)`
|
||||
// lands above the true bottom and the last rows may not render into the
|
||||
// window. Driving the last index through TanStack lands that row at the
|
||||
// bottom across its own measurement. Falls back to the raw pin when no
|
||||
// virtualizer is present (thread panel) or it declines.
|
||||
// Track a pending rAF so the cleanup can cancel it if the effect
|
||||
// re-runs (channel switch) before the frame fires.
|
||||
const pinToBottomOnMount = () => {
|
||||
// Set the anchor synchronously so settlingRef is correct below.
|
||||
anchorRef.current = { kind: "at-bottom" };
|
||||
// Defer the actual scroll call out of the layout effect. TanStack's
|
||||
// virtualizer calls flushSync(rerender) on scroll events when
|
||||
// useFlushSync=true (the default), which would trigger a synchronous
|
||||
// React re-render inside this layout effect and mutate the DOM before
|
||||
// Playwright (and the browser) finish measuring layout. Deferring to
|
||||
// rAF lets the current paint commit complete first.
|
||||
mountPinRafIdRef.current = requestAnimationFrame(() => {
|
||||
mountPinRafIdRef.current = null;
|
||||
if (!pinToBottomByIndexRef.current?.()) {
|
||||
scrollToBottomImperative("auto");
|
||||
}
|
||||
});
|
||||
};
|
||||
if (targetMessageId) {
|
||||
// A cold deep-link target may not be in the DOM on this first
|
||||
// commit — the route screen fetches it by id and splices it in a
|
||||
@@ -396,20 +574,27 @@ export function useAnchoredScroll({
|
||||
// the param sticks and re-clicking the same deep link is a no-op.
|
||||
onTargetReached?.(targetMessageId);
|
||||
} else {
|
||||
scrollToBottomImperative("auto");
|
||||
pinToBottomOnMount();
|
||||
}
|
||||
} else {
|
||||
scrollToBottomImperative("auto");
|
||||
pinToBottomOnMount();
|
||||
}
|
||||
hasInitializedRef.current = true;
|
||||
// Arm the settle window only when we pinned to bottom — that's the path
|
||||
// that suffers the late spacer growth. A successful deep-link target
|
||||
// leaves a message anchor and must not be treated as a settling bottom.
|
||||
settlingRef.current = anchorRef.current.kind === "at-bottom";
|
||||
prevLastMessageIdRef.current = messages[messages.length - 1]?.id;
|
||||
prevFirstMessageIdRef.current = messages[0]?.id;
|
||||
prevMessageCountRef.current = messages.length;
|
||||
return;
|
||||
}
|
||||
|
||||
const anchor = anchorRef.current;
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
const firstMessage = messages[0];
|
||||
const prevLastId = prevLastMessageIdRef.current;
|
||||
const prevFirstId = prevFirstMessageIdRef.current;
|
||||
const prevCount = prevMessageCountRef.current;
|
||||
const newLatestArrived =
|
||||
lastMessage !== undefined && lastMessage.id !== prevLastId;
|
||||
@@ -418,6 +603,36 @@ export function useAnchoredScroll({
|
||||
// same-second row, so the list grows without the *last* id changing —
|
||||
// `newLatestArrived` misses that case and the unread counter never bumps.
|
||||
const messagesArrived = messages.length - prevCount;
|
||||
// A convergence loop owns the scroll position while jumping to a windowed-out
|
||||
// target (its library reconcile is the sole writer). Cede every restore
|
||||
// branch to it — an at-bottom `scrollTo` here would chase the view back to
|
||||
// the bottom as the target's neighbours splice into the window mid-converge.
|
||||
// Refresh the tracked refs so the first post-settle commit isn't misread as
|
||||
// a prepend/append. Cleared when the target settles (targetMessageId null).
|
||||
if (convergingTargetIdRef.current !== null) {
|
||||
prevLastMessageIdRef.current = lastMessage?.id;
|
||||
prevFirstMessageIdRef.current = firstMessage?.id;
|
||||
prevMessageCountRef.current = messages.length;
|
||||
return;
|
||||
}
|
||||
// A load-older prepend grows the list at the FRONT while the tail is
|
||||
// unchanged. `useLoadOlderOnScroll` owns the prepend restore via its index
|
||||
// anchor (the single `scrollTop` writer). If this restore effect also ran
|
||||
// its anchored `scrollBy` on the same commit, two writers would fight over
|
||||
// `scrollTop`. So cede the prepend to the index path: refresh the tracked
|
||||
// refs and bail before the anchored branch. (Append and in-window reflow
|
||||
// leave the front id unchanged, so they fall through as before.)
|
||||
const isPrepend =
|
||||
firstMessage !== undefined &&
|
||||
prevFirstId !== undefined &&
|
||||
firstMessage.id !== prevFirstId &&
|
||||
!newLatestArrived;
|
||||
if (isPrepend) {
|
||||
prevLastMessageIdRef.current = lastMessage?.id;
|
||||
prevFirstMessageIdRef.current = firstMessage.id;
|
||||
prevMessageCountRef.current = messages.length;
|
||||
return;
|
||||
}
|
||||
|
||||
// One-shot: an outbound send armed `scrollToBottomOnNextUpdate`. When the
|
||||
// resulting append lands, snap to bottom regardless of the current anchor,
|
||||
@@ -430,6 +645,7 @@ export function useAnchoredScroll({
|
||||
setIsAtBottom(true);
|
||||
setNewMessageCount(0);
|
||||
prevLastMessageIdRef.current = lastMessage?.id;
|
||||
prevFirstMessageIdRef.current = firstMessage?.id;
|
||||
prevMessageCountRef.current = messages.length;
|
||||
return;
|
||||
}
|
||||
@@ -455,11 +671,10 @@ export function useAnchoredScroll({
|
||||
}
|
||||
|
||||
prevLastMessageIdRef.current = lastMessage?.id;
|
||||
prevFirstMessageIdRef.current = firstMessage?.id;
|
||||
prevMessageCountRef.current = messages.length;
|
||||
}, [
|
||||
isFetchingOlder,
|
||||
isLoading,
|
||||
layoutShiftKey,
|
||||
messages,
|
||||
onTargetReached,
|
||||
scrollContainerRef,
|
||||
@@ -468,102 +683,6 @@ export function useAnchoredScroll({
|
||||
targetMessageId,
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Older-history loader. IntersectionObserver on the top sentinel; when it
|
||||
// crosses into view (with a 200px rootMargin so we preload a bit early)
|
||||
// we fire `fetchOlder`. The anchor restoration above handles the prepend
|
||||
// — we don't need to compute or apply a scrollHeight delta ourselves.
|
||||
// ---------------------------------------------------------------------------
|
||||
React.useEffect(() => {
|
||||
const sentinel = sentinelRef.current;
|
||||
const container = scrollContainerRef.current;
|
||||
if (
|
||||
!sentinel ||
|
||||
!container ||
|
||||
!fetchOlder ||
|
||||
isLoading ||
|
||||
!hasOlderMessages
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
let observer: IntersectionObserver | null = null;
|
||||
let rearmFrame = 0;
|
||||
// Once the timeline is scrollable, a parked sentinel must not keep
|
||||
// re-firing: require it to actually leave and re-enter the preload band
|
||||
// (a real scroll) before the next fetch. Without this, re-observing a
|
||||
// still-intersecting sentinel synthesizes back-to-back fetches — the
|
||||
// "spinner flashes a few times then a burst of rows" on reply-heavy
|
||||
// channels. Auto-fill of a not-yet-scrollable page bypasses the gate.
|
||||
let mustExitBandBeforeFetch = false;
|
||||
|
||||
const start = () => {
|
||||
if (disposed) return;
|
||||
observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (!entry?.isIntersecting) {
|
||||
mustExitBandBeforeFetch = false;
|
||||
return;
|
||||
}
|
||||
if (disposed || fetchingOlderRef.current || mustExitBandBeforeFetch) {
|
||||
return;
|
||||
}
|
||||
|
||||
// One older fetch at a time. While a scroll-up is in flight, drop
|
||||
// further triggers outright rather than queueing retries — fast
|
||||
// scrolling otherwise stacks several sequential page loads. The
|
||||
// post-fetch re-arm fires the next page only when the sentinel is
|
||||
// still (or again) in the preload band.
|
||||
fetchingOlderRef.current = true;
|
||||
observer?.disconnect();
|
||||
|
||||
// Before the fetch, capture the anchor from the current scroll
|
||||
// position. The layout effect after re-render will use it.
|
||||
anchorRef.current = computeAnchor(container);
|
||||
|
||||
void fetchOlder()
|
||||
.catch(() => {
|
||||
// Swallow; the next intersection will retry. We don't want
|
||||
// to crash the observer chain on a transient relay error.
|
||||
})
|
||||
.finally(() => {
|
||||
fetchingOlderRef.current = false;
|
||||
// If the prepend made the timeline scrollable, require a real
|
||||
// scroll (sentinel leaving the band) before the next fetch.
|
||||
// A still-too-short page keeps auto-filling.
|
||||
mustExitBandBeforeFetch =
|
||||
container.scrollHeight - container.clientHeight >
|
||||
AT_BOTTOM_THRESHOLD_PX;
|
||||
// Re-observe next frame so the fresh observer's callback sees the
|
||||
// post-prepend intersection state.
|
||||
rearmFrame = window.requestAnimationFrame(() => {
|
||||
rearmFrame = 0;
|
||||
start();
|
||||
});
|
||||
});
|
||||
},
|
||||
{ root: container, rootMargin: "200px 0px 0px 0px" },
|
||||
);
|
||||
observer.observe(sentinel);
|
||||
};
|
||||
|
||||
start();
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (rearmFrame !== 0) {
|
||||
window.cancelAnimationFrame(rearmFrame);
|
||||
}
|
||||
observer?.disconnect();
|
||||
};
|
||||
}, [
|
||||
fetchOlder,
|
||||
hasOlderMessages,
|
||||
isLoading,
|
||||
scrollContainerRef,
|
||||
sentinelRef,
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Content resize: when fonts load late, an image decodes, an embed expands,
|
||||
// or any in-viewport reflow happens that React isn't driving (so the
|
||||
@@ -576,14 +695,35 @@ export function useAnchoredScroll({
|
||||
// async embed expand, late font load, markdown that expands) silently
|
||||
// pushes the reading row around.
|
||||
// ---------------------------------------------------------------------------
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: channelId is a deliberate re-subscription trigger — the effect body reads only the stable refs, but on a channel switch the keyed scroll container remounts and contentRef.current becomes a fresh node, so the observer must disconnect from the previous channel's detached node and re-observe the live one.
|
||||
React.useEffect(() => {
|
||||
const content = contentRef.current;
|
||||
if (!content || typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
// Cede entirely while a convergence loop owns scroll (jump to a
|
||||
// windowed-out target). Mid-jump the anchor is transiently `at-bottom`
|
||||
// — `computeAnchor` finds no crossing row until the virtualizer renders
|
||||
// rows at the new offset — so an unconditional re-pin here would yank
|
||||
// the in-flight jump down to the floor as rows measure. The convergence
|
||||
// loop is the sole writer until it settles (mirrors the layout effect's
|
||||
// `convergingTargetIdRef` bail).
|
||||
if (convergingTargetIdRef.current !== null) return;
|
||||
// Cede while the load-older index restore owns scroll. The prepended rows
|
||||
// measuring late is exactly what grows `scrollHeight` and fires this
|
||||
// observer; if it re-pinned here the anchor row is already windowed out,
|
||||
// so the all-gone fallback would pin to the floor and stomp the index
|
||||
// restore's correct offset. The index loop is the sole writer until it
|
||||
// settles (mirrors the layout effect's isPrepend bail).
|
||||
if (loadOlderRestoreInFlightRef.current) return;
|
||||
const anchor = anchorRef.current;
|
||||
if (anchor.kind === "at-bottom") {
|
||||
// Stuck to bottom: re-pin to the new floor. Virtualizer measurement
|
||||
// grows `scrollHeight` after the initial pin (rows below the fold
|
||||
// measure a frame or two late) without any `messages` change to drive
|
||||
// the layout effect, so this observer is the only thing that keeps the
|
||||
// view glued to the bottom as content settles.
|
||||
container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
|
||||
return;
|
||||
}
|
||||
@@ -601,7 +741,7 @@ export function useAnchoredScroll({
|
||||
});
|
||||
observer.observe(content);
|
||||
return () => observer.disconnect();
|
||||
}, [contentRef, scrollContainerRef]);
|
||||
}, [channelId, contentRef, scrollContainerRef]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Target message handling (deep link, jump-to-reply, etc.). Distinct from
|
||||
@@ -619,6 +759,7 @@ export function useAnchoredScroll({
|
||||
React.useEffect(() => {
|
||||
if (!targetMessageId) {
|
||||
handledTargetIdRef.current = null;
|
||||
convergingTargetIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (handledTargetIdRef.current === targetMessageId || isLoading) return;
|
||||
@@ -629,7 +770,23 @@ export function useAnchoredScroll({
|
||||
const el = container.querySelector<HTMLElement>(
|
||||
`[data-message-id="${targetMessageId}"]`,
|
||||
);
|
||||
if (!el) return; // Row not rendered yet; a later `messages` commit retries.
|
||||
if (!el) {
|
||||
// Row not in the DOM. In a virtualized list it may be windowed out and
|
||||
// never render from a passive commit, so delegate to the convergence
|
||||
// fallback: it drives the virtualizer to the target (warming up if a
|
||||
// deep-link target is still being fetched in) and, on settle, centers +
|
||||
// highlights it and fires `onTargetReached`. We mark the target handled
|
||||
// here so this effect stops re-dispatching, but deliberately do NOT fire
|
||||
// `onTargetReached` yet — clearing the route param now would cancel the
|
||||
// in-flight target fetch the loop is waiting on. Without a fallback
|
||||
// (thread panel), leave the target for a later `messages` commit.
|
||||
const converge = convergeToTargetRef.current;
|
||||
if (converge?.(targetMessageId)) {
|
||||
handledTargetIdRef.current = targetMessageId;
|
||||
convergingTargetIdRef.current = targetMessageId;
|
||||
}
|
||||
return;
|
||||
}
|
||||
handledTargetIdRef.current = targetMessageId;
|
||||
scrollToMessageImperative(targetMessageId, { highlight: true });
|
||||
onTargetReached?.(targetMessageId);
|
||||
@@ -658,5 +815,8 @@ export function useAnchoredScroll({
|
||||
scrollToBottom: scrollToBottomImperative,
|
||||
scrollToBottomOnNextUpdate,
|
||||
scrollToMessage: scrollToMessageImperative,
|
||||
restoreScrollPosition,
|
||||
setLoadOlderRestoreInFlight,
|
||||
getAnchorIsAtBottom,
|
||||
};
|
||||
}
|
||||
|
||||
192
desktop/src/features/messages/ui/useConvergentScrollToMessage.ts
Normal file
192
desktop/src/features/messages/ui/useConvergentScrollToMessage.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
type ConvergenceAlign,
|
||||
CONVERGENCE_FRAME_CAP,
|
||||
convergenceStep,
|
||||
} from "@/features/messages/lib/scrollConvergence";
|
||||
import type { ListVirtualizer } from "@/shared/ui/VirtualizedList";
|
||||
|
||||
/** Offset (px) within which the library is considered to have reached the target. */
|
||||
const SETTLE_TOLERANCE_PX = 2;
|
||||
|
||||
type ConvergentScrollOptions = {
|
||||
/** Live message-id -> item-index map, rebuilt with the flattened item stream. */
|
||||
indexByMessageId: Map<string, number>;
|
||||
/** Where the target should land in the viewport. */
|
||||
align: ConvergenceAlign;
|
||||
/** Fired on the settled frame once the target row has converged. */
|
||||
onConverged?: (messageId: string) => void;
|
||||
/** Fired when the loop stops without converging (target deleted, or frame cap). */
|
||||
onAbandoned?: (messageId: string) => void;
|
||||
};
|
||||
|
||||
type ConvergentScrollController = {
|
||||
/**
|
||||
* Begins a convergence loop toward `messageId`. Returns `true` (the loop
|
||||
* always starts and owns the target). When the id isn't yet in the data — a
|
||||
* deep-link target the route screen fetches asynchronously — the loop warms
|
||||
* up by polling the live map, then converges once it lands; if it never
|
||||
* lands within the frame cap it abandons (clears the highlight), the same
|
||||
* terminal state as a target deleted mid-settle. A new call cancels any
|
||||
* in-flight loop.
|
||||
*/
|
||||
scrollToMessage: (messageId: string) => boolean;
|
||||
/** Cancels any in-flight convergence loop (e.g. on unmount or channel switch). */
|
||||
cancel: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Drives @tanstack/react-virtual to settle on an off-screen message by id.
|
||||
*
|
||||
* The library already converges OFFSETS: one `scrollToIndex(i)` captures index
|
||||
* `i` and its `reconcileScroll` rAF loop re-aims as rows mount and measure. But
|
||||
* it chases the INDEX captured at call time — a prepend/delete mid-settle leaves
|
||||
* it on the wrong row. This adapter closes that gap: each frame it re-resolves
|
||||
* the target's CURRENT index from the live map (the pure `convergenceStep`
|
||||
* reducer owns the decision) and re-issues `scrollToIndex` ONLY when the index
|
||||
* moved. In steady state it issues nothing, so it never resets the library's
|
||||
* internal stable-frame counter and the library settles in one frame.
|
||||
*
|
||||
* Settle detection is a trivial offset-equality check (NOT the convergence math,
|
||||
* which the library owns): the measured offset for the current index is within
|
||||
* tolerance of where the library would place it, and the offset is unchanged
|
||||
* from the prior frame.
|
||||
*/
|
||||
export function useConvergentScrollToMessage(
|
||||
getVirtualizer: () => ListVirtualizer | null,
|
||||
{
|
||||
indexByMessageId,
|
||||
align,
|
||||
onConverged,
|
||||
onAbandoned,
|
||||
}: ConvergentScrollOptions,
|
||||
): ConvergentScrollController {
|
||||
// Mirror inputs into refs so the rAF loop closure always reads live values
|
||||
// without re-subscribing the loop each render.
|
||||
const mapRef = React.useRef(indexByMessageId);
|
||||
mapRef.current = indexByMessageId;
|
||||
const alignRef = React.useRef(align);
|
||||
alignRef.current = align;
|
||||
const onConvergedRef = React.useRef(onConverged);
|
||||
onConvergedRef.current = onConverged;
|
||||
const onAbandonedRef = React.useRef(onAbandoned);
|
||||
onAbandonedRef.current = onAbandoned;
|
||||
|
||||
const rafIdRef = React.useRef<number | null>(null);
|
||||
|
||||
const cancel = React.useCallback(() => {
|
||||
if (rafIdRef.current !== null) {
|
||||
cancelAnimationFrame(rafIdRef.current);
|
||||
rafIdRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scrollToMessage = React.useCallback(
|
||||
(messageId: string) => {
|
||||
cancel();
|
||||
|
||||
let lastIssuedIndex: number | null = null;
|
||||
let previousOffset: number | null = null;
|
||||
let framesUsed = 0;
|
||||
// The id->index map is rebuilt one render after `messages` changes, so a
|
||||
// freshly-spliced deep-link target (the route screen fetches it by id
|
||||
// asynchronously) can be absent from the map on the frame this starts.
|
||||
// Poll the live map during a bounded warmup instead of bailing; once it
|
||||
// resolves, hand off to the normal convergence loop. If it never resolves
|
||||
// within the cap, abandon — same terminal state as a deleted target.
|
||||
let resolved = mapRef.current.has(messageId);
|
||||
|
||||
const frame = () => {
|
||||
rafIdRef.current = null;
|
||||
const virtualizer = getVirtualizer();
|
||||
if (!virtualizer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIndex = mapRef.current.get(messageId);
|
||||
if (!resolved) {
|
||||
if (currentIndex === undefined) {
|
||||
if (framesUsed + 1 >= CONVERGENCE_FRAME_CAP) {
|
||||
onAbandonedRef.current?.(messageId);
|
||||
return;
|
||||
}
|
||||
framesUsed += 1;
|
||||
previousOffset = virtualizer.scrollOffset ?? 0;
|
||||
rafIdRef.current = requestAnimationFrame(frame);
|
||||
return;
|
||||
}
|
||||
resolved = true;
|
||||
}
|
||||
|
||||
// The library has settled this frame when its offset reached the target
|
||||
// index's offset (within tolerance) and stopped moving. `currentIndex`
|
||||
// is re-read so a settle on a stale index never counts as converged.
|
||||
let librarySettled = false;
|
||||
let stalledOffTarget = false;
|
||||
if (currentIndex !== undefined && lastIssuedIndex === currentIndex) {
|
||||
const offset = virtualizer.scrollOffset ?? 0;
|
||||
const target = virtualizer.getOffsetForIndex(
|
||||
currentIndex,
|
||||
alignRef.current,
|
||||
);
|
||||
const reachedTarget =
|
||||
target !== undefined &&
|
||||
Math.abs(offset - target[0]) <= SETTLE_TOLERANCE_PX;
|
||||
const offsetStable =
|
||||
previousOffset !== null &&
|
||||
Math.abs(offset - previousOffset) <= SETTLE_TOLERANCE_PX;
|
||||
librarySettled = reachedTarget && offsetStable;
|
||||
stalledOffTarget = offsetStable && !reachedTarget;
|
||||
previousOffset = offset;
|
||||
} else {
|
||||
previousOffset = virtualizer.scrollOffset ?? 0;
|
||||
}
|
||||
|
||||
const decision = convergenceStep({
|
||||
targetMessageId: messageId,
|
||||
indexByMessageId: mapRef.current,
|
||||
lastIssuedIndex,
|
||||
librarySettled,
|
||||
stalledOffTarget,
|
||||
framesUsed,
|
||||
});
|
||||
|
||||
if (
|
||||
decision.nextIndex !== null &&
|
||||
(decision.nextIndex !== lastIssuedIndex || decision.reissue)
|
||||
) {
|
||||
// Issue when the index moved (re-aim at the new row) OR the reducer
|
||||
// asks for a same-index re-issue to kick an off-target stall. Reset
|
||||
// `previousOffset` so the next frame's stability check restarts from
|
||||
// the post-issue offset rather than treating the stall as settled.
|
||||
virtualizer.scrollToIndex(decision.nextIndex, {
|
||||
align: alignRef.current,
|
||||
});
|
||||
lastIssuedIndex = decision.nextIndex;
|
||||
previousOffset = null;
|
||||
}
|
||||
|
||||
if (decision.done) {
|
||||
if (decision.converged) {
|
||||
onConvergedRef.current?.(messageId);
|
||||
} else {
|
||||
onAbandonedRef.current?.(messageId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
framesUsed += 1;
|
||||
rafIdRef.current = requestAnimationFrame(frame);
|
||||
};
|
||||
|
||||
rafIdRef.current = requestAnimationFrame(frame);
|
||||
return true;
|
||||
},
|
||||
[cancel, getVirtualizer],
|
||||
);
|
||||
|
||||
React.useEffect(() => cancel, [cancel]);
|
||||
|
||||
return { scrollToMessage, cancel };
|
||||
}
|
||||
318
desktop/src/features/messages/ui/useLoadOlderOnScroll.ts
Normal file
318
desktop/src/features/messages/ui/useLoadOlderOnScroll.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { CONVERGENCE_FRAME_CAP } from "@/features/messages/lib/scrollConvergence";
|
||||
import type { ListVirtualizer } from "@/shared/ui/VirtualizedList";
|
||||
|
||||
type UseLoadOlderOnScrollOptions = {
|
||||
fetchOlder?: () => Promise<void>;
|
||||
hasOlderMessages: boolean;
|
||||
isLoading: boolean;
|
||||
restoreScrollPosition: (scrollTop: number) => void;
|
||||
/**
|
||||
* Brackets the index-restore loop's scroll ownership so the anchored hook's
|
||||
* ResizeObserver cedes for the duration of the prepend. Without it, the
|
||||
* prepended rows measuring late fire the observer with the windowed-out
|
||||
* anchor and its all-gone fallback pins the view to the floor, stomping this
|
||||
* loop's correct re-aim. Only the virtualized (index) path needs it — the
|
||||
* non-virtualized path's restore is a single synchronous `scrollTop` write.
|
||||
*/
|
||||
setLoadOlderRestoreInFlight?: (inFlight: boolean) => void;
|
||||
/**
|
||||
* Live read of whether the user is stuck-to-bottom. If the user abandons the
|
||||
* fetch by jumping to bottom WHILE the index restore owns scroll, the restore
|
||||
* loop must re-aim at the bottom rather than the captured mid-history anchor
|
||||
* — otherwise it lands the old anchor offset (short of the true floor) and the
|
||||
* ceded ResizeObserver never chases the last rows down. Only meaningful with a
|
||||
* virtualizer; the non-virtualized path restores synchronously.
|
||||
*/
|
||||
getAnchorIsAtBottom?: () => boolean;
|
||||
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
|
||||
sentinelRef: React.RefObject<HTMLDivElement | null>;
|
||||
/**
|
||||
* When the timeline is virtualized, a prepend shifts every index and a large
|
||||
* one pushes the anchored row out of the window before it can be re-measured.
|
||||
* Supplying the virtualizer switches to an index anchor: we hold the
|
||||
* first-visible row across the prepend by re-aiming `scrollToIndex` at its new
|
||||
* index (resolved from `indexByMessageId`) until the library settles it.
|
||||
*/
|
||||
virtualizer?: {
|
||||
getVirtualizer: () => ListVirtualizer | null;
|
||||
indexByMessageId: Map<string, number>;
|
||||
itemCount: number;
|
||||
/** Live (non-deferred) message count for growth detection during load-older. */
|
||||
liveMessageCount?: number;
|
||||
} | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The scroll offset the prepend-restore loop should hold this frame. Returns
|
||||
* `undefined` when there's nothing to aim at (no virtualizer, no growth yet, or
|
||||
* the captured anchor row isn't resolvable). Resolved off the virtualizer's
|
||||
* live measurement each frame so the loop chases the offset as prepended rows
|
||||
* grow `getTotalSize()`.
|
||||
* - `abandonedToBottom`: the user jumped to bottom mid-prepend → the last
|
||||
* row's END offset (the true floor), not the stale mid-history anchor.
|
||||
* - otherwise: the captured first-visible row's START offset minus the gap
|
||||
* that was above it, holding the reader's eye-line across the prepend.
|
||||
*/
|
||||
function resolveTarget({
|
||||
instance,
|
||||
abandonedToBottom,
|
||||
lastIndex,
|
||||
newIndex,
|
||||
anchorTop,
|
||||
}: {
|
||||
instance: ListVirtualizer | null;
|
||||
abandonedToBottom: boolean;
|
||||
lastIndex: number;
|
||||
newIndex: number | undefined;
|
||||
anchorTop: number;
|
||||
}): number | undefined {
|
||||
if (!instance) return undefined;
|
||||
if (abandonedToBottom) {
|
||||
if (lastIndex < 0) return undefined;
|
||||
return instance.getOffsetForIndex(lastIndex, "end")?.[0];
|
||||
}
|
||||
if (newIndex === undefined) return undefined;
|
||||
const start = instance.getOffsetForIndex(newIndex, "start");
|
||||
return start === undefined ? undefined : start[0] - anchorTop;
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers `fetchOlder` when a sentinel element near the top of the scroll
|
||||
* container enters the viewport, then restores the scroll position so the
|
||||
* visible content doesn't jump.
|
||||
*/
|
||||
export function useLoadOlderOnScroll({
|
||||
fetchOlder,
|
||||
hasOlderMessages,
|
||||
isLoading,
|
||||
restoreScrollPosition,
|
||||
setLoadOlderRestoreInFlight,
|
||||
getAnchorIsAtBottom,
|
||||
scrollContainerRef,
|
||||
sentinelRef,
|
||||
virtualizer = null,
|
||||
}: UseLoadOlderOnScrollOptions) {
|
||||
const restoreScrollPositionRef = React.useRef(restoreScrollPosition);
|
||||
React.useEffect(() => {
|
||||
restoreScrollPositionRef.current = restoreScrollPosition;
|
||||
});
|
||||
// Mirror the cede setter so the long-lived Intersection observer reads the
|
||||
// live callback without re-subscribing (same rationale as the restore ref).
|
||||
const setInFlightRef = React.useRef(setLoadOlderRestoreInFlight);
|
||||
React.useEffect(() => {
|
||||
setInFlightRef.current = setLoadOlderRestoreInFlight;
|
||||
});
|
||||
// Mirror the at-bottom getter so the prepend loop reads the live abandon
|
||||
// state without re-subscribing the long-lived observer per render.
|
||||
const getAnchorIsAtBottomRef = React.useRef(getAnchorIsAtBottom);
|
||||
React.useEffect(() => {
|
||||
getAnchorIsAtBottomRef.current = getAnchorIsAtBottom;
|
||||
});
|
||||
// Mirror the virtualizer option into a ref so the long-lived Intersection
|
||||
// observer reads the live getter + count without re-subscribing per render.
|
||||
const virtualizerRef = React.useRef(virtualizer);
|
||||
virtualizerRef.current = virtualizer;
|
||||
|
||||
React.useEffect(() => {
|
||||
const sentinel = sentinelRef.current;
|
||||
const container = scrollContainerRef.current;
|
||||
if (
|
||||
!sentinel ||
|
||||
!container ||
|
||||
!fetchOlder ||
|
||||
isLoading ||
|
||||
!hasOlderMessages
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
let currentObserver: IntersectionObserver | null = null;
|
||||
|
||||
const observe = () => {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentObserver = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (!entry.isIntersecting || disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentObserver?.disconnect();
|
||||
|
||||
const virt = virtualizerRef.current;
|
||||
if (virt) {
|
||||
// Hold the first VISIBLE row across the prepend. After N older rows
|
||||
// are prepended the anchored row's INDEX shifts by N and — with
|
||||
// scrollTop unchanged near the top — it's pushed below the window
|
||||
// and recycled out of the DOM, so a pure DOM re-read can't find it.
|
||||
// We therefore drive the virtualizer: capture the row's id + its top
|
||||
// offset in the viewport now, and after the prepend re-aim
|
||||
// `scrollToIndex(newIndex, "start")` each frame (re-issued only when
|
||||
// the resolved index moves, so the library's internal settle loop is
|
||||
// never reset — same single-issue discipline as the convergence
|
||||
// adapter). `scrollToIndex` re-aims internally as rows mount and
|
||||
// measure, landing the row's TOP at the viewport top; once it settles
|
||||
// we apply the captured intra-viewport gap with ONE scrollTop write.
|
||||
// Single writer throughout: one mechanism re-aims, the gap is a final
|
||||
// one-shot, never an overlapping second target.
|
||||
const instance = virt.getVirtualizer();
|
||||
const container = scrollContainerRef.current;
|
||||
// Use liveMessageCount (live, non-deferred) rather than itemCount
|
||||
// or instance.options.count — both are derived from deferredMessages
|
||||
// and are stale during a fetch, so the growth check would never fire.
|
||||
const previousCount = virt.liveMessageCount ?? virt.itemCount;
|
||||
|
||||
void fetchOlder().then(() => {
|
||||
// Claim scroll ownership for the whole re-aim window so the
|
||||
// anchored hook's ResizeObserver cedes while the prepended rows
|
||||
// measure (released at every exit below via `finishPrepend`).
|
||||
setInFlightRef.current?.(true);
|
||||
// Capture the anchor at fetch-RESOLVE time, not sentinel-fire
|
||||
// time: the history request can be in flight for a while and the
|
||||
// user may keep scrolling during it (the e2e scrolls further down
|
||||
// mid-fetch). The row+offset we must preserve is wherever the
|
||||
// reader actually is the instant before the prepend commits, read
|
||||
// from the live DOM here while every visible row is still mounted.
|
||||
const containerRect = container?.getBoundingClientRect();
|
||||
const containerTop = containerRect?.top ?? 0;
|
||||
const containerBottom = containerRect?.bottom ?? 0;
|
||||
// First row intersecting the viewport — the reader's eye-line row.
|
||||
// Geometry matches the test's getFirstVisibleMessage exactly: its
|
||||
// bottom is below the viewport top and its top is above the
|
||||
// viewport bottom.
|
||||
const anchorRow = container
|
||||
? Array.from(
|
||||
container.querySelectorAll<HTMLElement>(
|
||||
"[data-message-id]",
|
||||
),
|
||||
).find((row) => {
|
||||
const rect = row.getBoundingClientRect();
|
||||
return (
|
||||
rect.bottom > containerTop && rect.top < containerBottom
|
||||
);
|
||||
})
|
||||
: undefined;
|
||||
const anchorId = anchorRow?.dataset.messageId ?? null;
|
||||
// The anchored row's top relative to the viewport top — held
|
||||
// constant across the prepend.
|
||||
const anchorTop = anchorRow
|
||||
? anchorRow.getBoundingClientRect().top - containerTop
|
||||
: 0;
|
||||
|
||||
// The timeline drives its rows off a `useDeferredValue` of the
|
||||
// message list, so the prepended items commit on a LOW-priority
|
||||
// render that can land several frames after `fetchOlder` resolves.
|
||||
// Poll rAF until the live id->index map actually shifts the anchor
|
||||
// (the prepend is observable), capped so an empty fetch can't spin.
|
||||
const maxFrames = CONVERGENCE_FRAME_CAP;
|
||||
let frame = 0;
|
||||
let lastTarget: number | null = null;
|
||||
let stableFrames = 0;
|
||||
// Release scroll ownership (re-enabling the ResizeObserver) and
|
||||
// re-arm the sentinel observer. Called at both loop exits.
|
||||
const finishPrepend = () => {
|
||||
setInFlightRef.current?.(false);
|
||||
observe();
|
||||
};
|
||||
const waitForPrepend = () => {
|
||||
const after = virtualizerRef.current;
|
||||
const grew =
|
||||
(after?.liveMessageCount ?? previousCount) > previousCount;
|
||||
// Resolve this frame's target offset. Two cases, one mechanism:
|
||||
// - Abandon: the user jumped to bottom while this loop owned
|
||||
// scroll. Hold the BOTTOM (last row's end offset), not the
|
||||
// captured mid-history anchor — that old offset sits short of
|
||||
// the true floor and would strand the view there, since the
|
||||
// ResizeObserver re-pin is ceded to this loop for the whole
|
||||
// window.
|
||||
// - Normal: hold the captured first-visible row at its viewport
|
||||
// gap (its start offset minus the gap that was above it).
|
||||
// Either way we drive `scrollToOffset` — NOT `scrollToIndex` — so
|
||||
// the library's reconcile holds a FIXED offset instead of
|
||||
// re-resolving each frame and overwriting it. As the prepended
|
||||
// rows measure, `getOffsetForIndex` grows and we recompute and
|
||||
// re-issue. Re-issue ONLY when the target moves — re-issuing an
|
||||
// unchanged offset resets the library's stable-frame counter and
|
||||
// spins. Same single-issue discipline as the convergence adapter.
|
||||
const abandonedToBottom =
|
||||
getAnchorIsAtBottomRef.current?.() ?? false;
|
||||
const newIndex =
|
||||
anchorId !== null
|
||||
? after?.indexByMessageId.get(anchorId)
|
||||
: undefined;
|
||||
const target = resolveTarget({
|
||||
instance: grew ? instance : null,
|
||||
abandonedToBottom,
|
||||
lastIndex: (after?.liveMessageCount ?? previousCount) - 1,
|
||||
newIndex,
|
||||
anchorTop,
|
||||
});
|
||||
if (instance && target !== undefined) {
|
||||
if (target !== lastTarget) {
|
||||
instance.scrollToOffset(target, { align: "start" });
|
||||
lastTarget = target;
|
||||
stableFrames = 0;
|
||||
} else if ((instance.scrollOffset ?? 0) >= target) {
|
||||
// Reached the target and it stopped moving (measurement
|
||||
// settled). `>=` not `===`: the abandon path aims at the
|
||||
// last possible offset, which the container clamps, so the
|
||||
// realized `scrollOffset` can sit at-or-past it. Two stable
|
||||
// frames guard against ending before the last row measures.
|
||||
stableFrames += 1;
|
||||
if (stableFrames >= 2) {
|
||||
finishPrepend();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
frame += 1;
|
||||
if (frame >= maxFrames) {
|
||||
finishPrepend();
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(waitForPrepend);
|
||||
};
|
||||
requestAnimationFrame(waitForPrepend);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const previousHeight = container.scrollHeight;
|
||||
const previousScrollTop = container.scrollTop;
|
||||
void fetchOlder().then(() => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const newHeight = container.scrollHeight;
|
||||
const delta = newHeight - previousHeight;
|
||||
if (delta > 0) {
|
||||
restoreScrollPositionRef.current(previousScrollTop + delta);
|
||||
}
|
||||
observe();
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
{ root: container, rootMargin: "200px 0px 0px 0px" },
|
||||
);
|
||||
|
||||
currentObserver.observe(sentinel);
|
||||
};
|
||||
|
||||
observe();
|
||||
return () => {
|
||||
disposed = true;
|
||||
currentObserver?.disconnect();
|
||||
};
|
||||
}, [
|
||||
fetchOlder,
|
||||
hasOlderMessages,
|
||||
isLoading,
|
||||
scrollContainerRef,
|
||||
sentinelRef,
|
||||
]);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type Virtualizer, useVirtualizer } from "@tanstack/react-virtual";
|
||||
import * as React from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
@@ -16,8 +17,15 @@ export type ListVirtualizer = Virtualizer<HTMLElement, Element>;
|
||||
* dynamic sizing automatically.
|
||||
*
|
||||
* Supports:
|
||||
* (a) Optional non-virtualized sticky-header slot rendered above the virtual
|
||||
* rows inside the scroll container (for PulseView's sticky composer, etc).
|
||||
* (a) Optional floating header fed the topmost visible row index. It is
|
||||
* portaled into a caller-owned container OUTSIDE the scroll element
|
||||
* (`headerOverlayRef`) so it pins to a fixed viewport offset regardless of
|
||||
* scroll position — a `position: sticky` header inside the scroll element
|
||||
* drifts at scrollTop 0, where it reveals its natural flow offset instead
|
||||
* of the pinned offset. The render fn still re-runs with the virtualizer on
|
||||
* every scroll (the absolutely-positioned rows cannot stay `position:
|
||||
* sticky` once they leave the virtual window), but the re-render stays
|
||||
* localized here rather than forcing the caller to re-render per scroll.
|
||||
* (b) Optional externally-owned scroll container — pass `scrollRef` when the
|
||||
* caller already owns the scrolling element (a surface that shares its
|
||||
* scroll region with non-row siblings). When omitted, VirtualizedList
|
||||
@@ -32,8 +40,20 @@ type VirtualizedListProps<T> = {
|
||||
renderItem: (item: T, index: number) => React.ReactNode;
|
||||
/** Estimated row height in px — used before measurement. */
|
||||
estimateSize?: number;
|
||||
/** Optional non-virtualized content rendered above the virtual rows (sticky headers, etc). */
|
||||
stickyHeader?: React.ReactNode;
|
||||
/**
|
||||
* Optional floating header rendered with the index of the topmost visible
|
||||
* row (so it can label what's on screen). When `headerOverlayRef` points at a
|
||||
* mounted element, the result is portaled there — a non-scrolling container
|
||||
* outside the scroll element — so it pins to a fixed offset and cannot drift
|
||||
* with scroll position. `null` before the first row is in view.
|
||||
*/
|
||||
stickyHeader?: (topVisibleIndex: number | null) => React.ReactNode;
|
||||
/**
|
||||
* Portal target for `stickyHeader`. Must sit OUTSIDE the scroll element (a
|
||||
* `position: sticky` header inside it drifts at scrollTop 0). The header is
|
||||
* portaled here only once the ref attaches.
|
||||
*/
|
||||
headerOverlayRef?: React.RefObject<HTMLElement | null>;
|
||||
/**
|
||||
* Externally-owned scroll container. When provided, no internal scroll
|
||||
* container is rendered — the caller's element scrolls and is measured.
|
||||
@@ -55,6 +75,7 @@ export function VirtualizedList<T>({
|
||||
renderItem,
|
||||
estimateSize = 80,
|
||||
stickyHeader,
|
||||
headerOverlayRef,
|
||||
scrollRef,
|
||||
className,
|
||||
innerClassName,
|
||||
@@ -100,19 +121,37 @@ export function VirtualizedList<T>({
|
||||
scrollMargin,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
// Register in a layout effect, not a passive one: when this list remounts on
|
||||
// channel switch the parent's mount-pin runs in a layout effect on the same
|
||||
// commit, and child layout effects fire before parent layout effects. A
|
||||
// passive effect would publish the fresh virtualizer too late and the parent
|
||||
// would pin against the previous channel's stale instance.
|
||||
React.useLayoutEffect(() => {
|
||||
onVirtualizer?.(virtualizer);
|
||||
}, [onVirtualizer, virtualizer]);
|
||||
|
||||
const virtualItems = virtualizer.getVirtualItems();
|
||||
|
||||
// The header portals into `headerOverlayRef`, which the parent mounts before
|
||||
// this child — so it is populated on first commit. The dependency-free state
|
||||
// flip re-renders once after mount to cover the rare case where the parent
|
||||
// attaches the ref in the same commit as our first paint.
|
||||
const [headerHost, setHeaderHost] = React.useState<HTMLElement | null>(null);
|
||||
React.useEffect(() => {
|
||||
setHeaderHost(headerOverlayRef?.current ?? null);
|
||||
}, [headerOverlayRef]);
|
||||
|
||||
const header = stickyHeader?.(virtualItems[0]?.index ?? null);
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{stickyHeader}
|
||||
{headerHost && header ? createPortal(header, headerHost) : null}
|
||||
<div
|
||||
className={cn("relative w-full", innerClassName)}
|
||||
ref={spacerRef}
|
||||
style={{ height: `${virtualizer.getTotalSize()}px` }}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualRow) => (
|
||||
{virtualItems.map((virtualRow) => (
|
||||
<div
|
||||
data-index={virtualRow.index}
|
||||
key={virtualRow.key}
|
||||
|
||||
@@ -1569,6 +1569,37 @@ const mockChannels: MockChannel[] = [
|
||||
createMockMember(MOCK_IDENTITY_PUBKEY, "member", 700),
|
||||
],
|
||||
}),
|
||||
// Deep history channel for the load-older-under-virtualization E2E. Seeded
|
||||
// with more messages than CHANNEL_HISTORY_LIMIT (300) so the initial load
|
||||
// windows to the newest page and a `fetchOlder` (until-cursor) prepend has
|
||||
// genuinely older rows to add — exercising the scroll-restore anchor under
|
||||
// virtualization. Its own channel so existing channels' row-index and unread
|
||||
// assertions stay undisturbed.
|
||||
createMockChannel({
|
||||
id: "feedf00d-0000-4000-8000-000000000007",
|
||||
name: "deep-history",
|
||||
channel_type: "stream",
|
||||
visibility: "open",
|
||||
description: "Channel with paginated history for load-older tests",
|
||||
topic: null,
|
||||
purpose: null,
|
||||
last_message_at: isoMinutesAgo(1),
|
||||
archived_at: null,
|
||||
created_by: ALICE_PUBKEY,
|
||||
topic_set_by: null,
|
||||
topic_set_at: null,
|
||||
purpose_set_by: null,
|
||||
purpose_set_at: null,
|
||||
topic_required: false,
|
||||
max_members: null,
|
||||
nip29_group_id: null,
|
||||
created_minutes_ago: 2000,
|
||||
updated_minutes_ago: 1,
|
||||
members: [
|
||||
createMockMember(ALICE_PUBKEY, "owner", 2000),
|
||||
createMockMember(MOCK_IDENTITY_PUBKEY, "member", 1900),
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const mockMessages = new Map<string, RelayEvent[]>();
|
||||
@@ -2374,7 +2405,24 @@ function getMockMessageStore(channelId: string): RelayEvent[] {
|
||||
sig: "mocksig".repeat(20).slice(0, 128),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
: channelId === "feedf00d-0000-4000-8000-000000000007"
|
||||
? // 600 messages > CHANNEL_HISTORY_LIMIT (300): the initial load
|
||||
// windows to the newest 300, leaving 300 older behind the until
|
||||
// cursor — enough for several full fetchOlder pages (batch 100),
|
||||
// so the load-older anchor restore is exercised across REPEATED
|
||||
// prepend cycles, not a single lucky pass. created_at increases
|
||||
// with index (oldest first) so message N+1 is newer than N — the
|
||||
// anchor restores the first-visible row across each prepend.
|
||||
Array.from({ length: 600 }, (_, index) => ({
|
||||
id: `mock-deep-history-${index}`,
|
||||
pubkey: index % 2 === 0 ? ALICE_PUBKEY : MOCK_IDENTITY_PUBKEY,
|
||||
created_at: Math.floor(Date.now() / 1000) - (600 - index) * 60,
|
||||
kind: 9,
|
||||
tags: [["h", channelId]],
|
||||
content: `Deep history message #${index}`,
|
||||
sig: "mocksig".repeat(20).slice(0, 128),
|
||||
}))
|
||||
: [];
|
||||
|
||||
mockMessages.set(channelId, seeded);
|
||||
return seeded;
|
||||
|
||||
@@ -279,7 +279,7 @@ async function expectSameLeftInset(
|
||||
expect(Math.abs(firstBox.x - secondBox.x)).toBeLessThanOrEqual(4);
|
||||
}
|
||||
|
||||
async function expectIntroBalancedAroundDayDivider(
|
||||
async function expectIntroSpacedAboveDayDivider(
|
||||
page: import("@playwright/test").Page,
|
||||
introTestId: string,
|
||||
) {
|
||||
@@ -300,7 +300,17 @@ async function expectIntroBalancedAroundDayDivider(
|
||||
const gapAboveDivider = dividerBox.y - (introBox.y + introBox.height);
|
||||
const gapBelowDivider = messageBox.y - (dividerBox.y + dividerBox.height);
|
||||
|
||||
expect(Math.abs(gapAboveDivider - gapBelowDivider)).toBeLessThanOrEqual(1);
|
||||
// The intro is a flex sibling above the timeline; the day divider and first
|
||||
// message-row are virtualized items positioned by translateY inside the
|
||||
// scroll container. The intro -> divider gap is the wrapper flex spacing the
|
||||
// layout controls (8px, stable), so guard THAT with a tight band — a layout
|
||||
// regression that collapses or balloons it fails here. The divider -> message
|
||||
// gap is NOT a layout-spacing contract: virtualized rows are positioned
|
||||
// back-to-back (no inter-item gap), so it is ~0 by construction plus
|
||||
// MessageRow avatar/font render jitter, genuinely variable run-to-run. Assert
|
||||
// only non-overlap on it (reading order: intro, divider, then message).
|
||||
expect(Math.abs(gapAboveDivider - 8)).toBeLessThanOrEqual(2);
|
||||
expect(gapBelowDivider).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
|
||||
async function expectIntroActionCardLayout(
|
||||
@@ -1157,7 +1167,7 @@ test("sidebar clears unread indicator after opening a DM", async ({ page }) => {
|
||||
"Unread update for the DM",
|
||||
);
|
||||
await expectSameLeftInset(page, "message-dm-intro", "message-row");
|
||||
await expectIntroBalancedAroundDayDivider(page, "message-dm-intro");
|
||||
await expectIntroSpacedAboveDayDivider(page, "message-dm-intro");
|
||||
await expect(page.getByTestId("channel-unread-alice-tyler")).toHaveCount(0);
|
||||
});
|
||||
|
||||
|
||||
166
desktop/tests/e2e/cold-switch-longtask.perf.ts
Normal file
166
desktop/tests/e2e/cold-switch-longtask.perf.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
|
||||
/**
|
||||
* Cold-channel-switch longtask harness.
|
||||
*
|
||||
* This is the instrument the timeline-virtualization acceptance gate is defined
|
||||
* against: it measures the main-thread blocking cost of the FIRST switch into a
|
||||
* deep channel (600 seeded messages, windowed to the 300-row ceiling on cold
|
||||
* load). main builds every windowed row synchronously on first mount, so that
|
||||
* mount is the beachball; virtualization renders only the visible window, so the
|
||||
* mount cost is bounded and independent of channel depth.
|
||||
*
|
||||
* WHY LONGTASKS, NOT LAYOUT METRICS: the felt jank is the main thread being
|
||||
* blocked past the ~50ms frame-budget wall during the mount. PerformanceObserver
|
||||
* `longtask` entries are exactly the >50ms main-thread tasks the browser itself
|
||||
* flags — the honest, engine-level signal for "the UI froze". We report the
|
||||
* LONGEST single longtask (the worst freeze) and the TOTAL longtask time across
|
||||
* the switch window (the split-task guard axis — many medium tasks can hide the
|
||||
* same total cost a single long one would show).
|
||||
*
|
||||
* WHY 4x CPU THROTTLE: headless Chromium on dev hardware is far faster than the
|
||||
* target machines; 4x throttle via CDP brings the mount cost into a regime where
|
||||
* a 300-row synchronous build actually crosses the frame-budget wall, so the
|
||||
* measurement discriminates. The throttle and machine cancel in the B-vs-header
|
||||
* delta gate, so absolute ms are not portable but the delta is.
|
||||
*
|
||||
* COLD = warm `general` first, then the FIRST switch into `deep-history`. A warm
|
||||
* re-entry hits cached render state and does not reproduce the cold mount cost.
|
||||
* Each of the 5 runs re-warms `general` so every deep-history switch is cold.
|
||||
*
|
||||
* SCOPE LIMIT: this measures Chromium main-thread longtasks under throttle. It
|
||||
* does NOT measure the WKWebView compositor feel on the shipped Tauri shell —
|
||||
* that is a separate real-wheel pass.
|
||||
*
|
||||
* Run it:
|
||||
* pnpm build && npx playwright test --config=playwright.perf.config.ts \
|
||||
* cold-switch-longtask.perf.ts
|
||||
*/
|
||||
|
||||
const RUNS = 5;
|
||||
const THROTTLE_RATE = 4;
|
||||
|
||||
type RunResult = { longest: number; total: number; count: number };
|
||||
|
||||
function median(values: number[]): number {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 === 0
|
||||
? (sorted[mid - 1] + sorted[mid]) / 2
|
||||
: sorted[mid];
|
||||
}
|
||||
|
||||
test("MEASURE: cold-switch longtask cost into deep-history at the 300 ceiling", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await installMockBridge(page);
|
||||
await page.goto("/");
|
||||
await page.waitForFunction(
|
||||
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
|
||||
);
|
||||
|
||||
// Arm a longtask observer that buffers into a window array we can read and
|
||||
// reset per run. `buffered: true` catches tasks queued before the read.
|
||||
await page.addInitScript(() => {
|
||||
const store = window as unknown as { __LONGTASKS__?: number[] };
|
||||
store.__LONGTASKS__ = [];
|
||||
new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
store.__LONGTASKS__?.push(entry.duration);
|
||||
}
|
||||
}).observe({ type: "longtask", buffered: true });
|
||||
});
|
||||
// addInitScript only applies on the next navigation, so reload to arm it.
|
||||
await page.reload();
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" &&
|
||||
Array.isArray(
|
||||
(window as unknown as { __LONGTASKS__?: number[] }).__LONGTASKS__,
|
||||
),
|
||||
);
|
||||
|
||||
const client = await page.context().newCDPSession(page);
|
||||
await client.send("Emulation.setCPUThrottlingRate", { rate: THROTTLE_RATE });
|
||||
|
||||
const timeline = page.getByTestId("message-timeline");
|
||||
const results: RunResult[] = [];
|
||||
|
||||
for (let run = 0; run < RUNS; run += 1) {
|
||||
// Warm `general` so the deep-history switch that follows is a cold first
|
||||
// entry, not a warm re-render of cached state.
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
|
||||
|
||||
// Clear the buffer immediately before the cold switch so only the switch's
|
||||
// longtasks are attributed to this run.
|
||||
await page.evaluate(() => {
|
||||
(window as unknown as { __LONGTASKS__: number[] }).__LONGTASKS__ = [];
|
||||
});
|
||||
|
||||
// The cold switch: first entry into the 600-message channel. The cold load
|
||||
// windows to the newest 300 and mounts them.
|
||||
await page.getByTestId("channel-deep-history").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("deep-history");
|
||||
await expect(
|
||||
page.locator('[data-message-id^="mock-deep-history-"]').first(),
|
||||
).toBeVisible();
|
||||
// Let any post-mount longtasks (anchor settle, sticky handoff) flush before
|
||||
// reading — they are part of the switch cost.
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const tasks = await page.evaluate(
|
||||
() =>
|
||||
(window as unknown as { __LONGTASKS__: number[] }).__LONGTASKS__ ?? [],
|
||||
);
|
||||
results.push({
|
||||
longest: tasks.length ? Math.max(...tasks) : 0,
|
||||
total: tasks.reduce((sum, d) => sum + d, 0),
|
||||
count: tasks.length,
|
||||
});
|
||||
}
|
||||
|
||||
await client.send("Emulation.setCPUThrottlingRate", { rate: 1 });
|
||||
|
||||
const longests = results.map((r) => r.longest);
|
||||
const totals = results.map((r) => r.total);
|
||||
const medianLongest = median(longests);
|
||||
const minLongest = Math.min(...longests);
|
||||
const maxLongest = Math.max(...longests);
|
||||
const spread = maxLongest - minLongest;
|
||||
const medianTotal = median(totals);
|
||||
|
||||
/* eslint-disable no-console */
|
||||
console.log(
|
||||
"\n=== COLD-SWITCH LONGTASK BASELINE (deep-history, 300 ceiling) ===",
|
||||
);
|
||||
console.log(`CPU throttle: ${THROTTLE_RATE}x`);
|
||||
console.log(`runs: ${RUNS}`);
|
||||
console.log(
|
||||
`per-run longest-longtask (ms): [${longests.map((v) => v.toFixed(1)).join(", ")}]`,
|
||||
);
|
||||
console.log(
|
||||
`per-run total-longtask (ms): [${totals.map((v) => v.toFixed(1)).join(", ")}]`,
|
||||
);
|
||||
console.log(
|
||||
`per-run longtask count: [${results.map((r) => r.count).join(", ")}]`,
|
||||
);
|
||||
console.log(`MEDIAN longest-longtask: ${medianLongest.toFixed(1)}ms`);
|
||||
console.log(
|
||||
` spread (max - min): ${spread.toFixed(1)}ms (min ${minLongest.toFixed(1)}, max ${maxLongest.toFixed(1)})`,
|
||||
);
|
||||
console.log(`MEDIAN total-longtask-in-window: ${medianTotal.toFixed(1)}ms`);
|
||||
console.log("(>50ms single task is a dropped-frame freeze the user feels)");
|
||||
console.log(
|
||||
"=================================================================\n",
|
||||
);
|
||||
/* eslint-enable no-console */
|
||||
|
||||
// Instrument, not a gate — confirm the switch actually exercised the mount
|
||||
// under throttle (some longtask work happened on at least one run).
|
||||
expect(results.some((r) => r.count > 0)).toBe(true);
|
||||
});
|
||||
@@ -175,11 +175,32 @@ test("reconnect backfills more missed channel messages than the live subscriptio
|
||||
}));
|
||||
await emitMockMessages(page, missedMessages);
|
||||
|
||||
await expect(page.getByTestId("message-timeline")).toContainText(
|
||||
"reconnect e2e missed 001",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await expect(page.getByTestId("message-timeline")).toContainText(
|
||||
"reconnect e2e missed 260",
|
||||
);
|
||||
// The newest backfilled message renders at the bottom once the reconnect
|
||||
// catch-up settles.
|
||||
const timeline = page.getByTestId("message-timeline");
|
||||
await expect(timeline).toContainText("reconnect e2e missed 260", {
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
// The virtualized timeline windows the oldest backfilled rows out of the DOM
|
||||
// while the user sits at the bottom, so the backfill depth can't be asserted
|
||||
// by expecting all 260 rows to be mounted at once. Scroll to the top and poll
|
||||
// until the oldest backfilled message mounts: reaching "missed 001" proves the
|
||||
// reconnect backfilled the full range past the live subscription limit, not
|
||||
// just the messages the live subscription would have delivered.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await timeline.evaluate((element) => {
|
||||
const scrollable = element as HTMLDivElement;
|
||||
scrollable.scrollTop = 0;
|
||||
scrollable.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
});
|
||||
return timeline.evaluate((element) =>
|
||||
(element.textContent ?? "").includes("reconnect e2e missed 001"),
|
||||
);
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
@@ -61,6 +61,30 @@ async function getMessagePosition(
|
||||
}, messageId);
|
||||
}
|
||||
|
||||
// The floating active-day header (ActiveDayHeader) is portaled OUTSIDE the
|
||||
// scroll container into a non-scrolling overlay, so it pins to a fixed offset
|
||||
// instead of drifting at scrollTop 0. We read its label + offset relative to
|
||||
// the scroll container's top so a test can assert it neither drifts nor flips
|
||||
// day as older history prepends above the anchor.
|
||||
async function getActiveDayHeader(page: import("@playwright/test").Page) {
|
||||
return page.evaluate(() => {
|
||||
const timeline = document.querySelector<HTMLElement>(
|
||||
'[data-testid="message-timeline"]',
|
||||
);
|
||||
const pill = document.querySelector<HTMLElement>(
|
||||
'[data-testid="message-timeline-active-day-header"]',
|
||||
);
|
||||
if (!timeline || !pill) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
label: pill.dataset.dayLabel ?? pill.textContent ?? "",
|
||||
top:
|
||||
pill.getBoundingClientRect().top - timeline.getBoundingClientRect().top,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
test("first channel load holds skeleton instead of showing older-history spinner", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -111,47 +135,25 @@ test("first channel load holds skeleton instead of showing older-history spinner
|
||||
|
||||
test("preserves user scroll while older channel history loads", async ({
|
||||
page,
|
||||
}) => {
|
||||
}, testInfo) => {
|
||||
testInfo.setTimeout(60_000);
|
||||
await installMockBridge(page);
|
||||
await page.goto("/");
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" &&
|
||||
typeof window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__ === "function",
|
||||
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
|
||||
);
|
||||
|
||||
await page.evaluate(() => {
|
||||
for (let index = 0; index < 40; index += 1) {
|
||||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "general",
|
||||
content: `visible current ${index}\nsecond line ${index}`,
|
||||
});
|
||||
}
|
||||
window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__?.({
|
||||
channelName: "general",
|
||||
count: 600,
|
||||
lineCount: 3,
|
||||
});
|
||||
});
|
||||
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
// Use the `deep-history` channel: its store is seeded with 600 messages,
|
||||
// more than CHANNEL_HISTORY_LIMIT (300, hooks.ts), so the cold load windows
|
||||
// to the newest 300 and leaves ~300 genuinely older messages behind the
|
||||
// `until` cursor. A shallow seed (store < 300) is fully drained by the cold
|
||||
// load, so the wheel `fetchOlder` returns only already-cached duplicates that
|
||||
// dedup to zero net growth -- the anchor never has a real prepend to hold and
|
||||
// the assertion would measure virtualizer re-measure, not scroll preservation.
|
||||
await page.getByTestId("channel-deep-history").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("deep-history");
|
||||
const timeline = page.getByTestId("message-timeline");
|
||||
await expect(timeline).toContainText("visible current 39");
|
||||
|
||||
// Initial load should receive enough history to make the page scrollable.
|
||||
// Delay only the next history request, so the test isolates pagination while
|
||||
// the user is actively scrolling.
|
||||
await page.evaluate(() => {
|
||||
window.__BUZZ_E2E__ = {
|
||||
...window.__BUZZ_E2E__,
|
||||
mock: {
|
||||
...window.__BUZZ_E2E__?.mock,
|
||||
historyDelayMs: 1_000,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector(
|
||||
'[data-testid="message-timeline"]',
|
||||
@@ -159,54 +161,134 @@ test("preserves user scroll while older channel history loads", async ({
|
||||
return element && element.scrollHeight > element.clientHeight + 1000;
|
||||
});
|
||||
|
||||
// Move away from the bottom before jumping near the top; otherwise the
|
||||
// timeline's sticky-bottom guard can intentionally pin the first upward jump.
|
||||
const beforeFetch = await getTimelineMetrics(page);
|
||||
await timeline.evaluate((element) => {
|
||||
const timelineElement = element as HTMLDivElement;
|
||||
timelineElement.scrollTop = timelineElement.scrollHeight;
|
||||
timelineElement.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
});
|
||||
await page.waitForTimeout(50);
|
||||
// The lowest "Deep history message #N" index currently rendered. The seed
|
||||
// numbers messages 0 (oldest) .. 599 (newest), so a smaller index = older.
|
||||
const oldestRenderedIndex = () =>
|
||||
timeline.evaluate((element) => {
|
||||
let min = Number.POSITIVE_INFINITY;
|
||||
for (const row of (
|
||||
element as HTMLDivElement
|
||||
).querySelectorAll<HTMLElement>("[data-message-id]")) {
|
||||
const match = row.textContent?.match(/#(\d+)/);
|
||||
if (match) min = Math.min(min, Number(match[1]));
|
||||
}
|
||||
return Number.isFinite(min) ? min : null;
|
||||
});
|
||||
|
||||
const nearTop = await timeline.evaluate((element) => {
|
||||
const timelineElement = element as HTMLDivElement;
|
||||
timelineElement.scrollTop = 180;
|
||||
timelineElement.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
return timelineElement.scrollTop;
|
||||
});
|
||||
expect(nearTop).toBeLessThan(260);
|
||||
// PHASE 1 -- walk into mid-history with NO history delay. Each fetchOlder
|
||||
// resolves instantly, the prepend lands, the oldest-rendered index advances,
|
||||
// and the next wheel re-enters a fresh top sentinel. This sustained climb is
|
||||
// how a deep seed reaches the sentinel under real wheel (the `count:2100`
|
||||
// sibling relies on the same mechanic). We stop in mid-history -- not at the
|
||||
// top -- so a genuine older page still sits behind the `until` cursor for
|
||||
// phase 2 to fetch, and so the anchor we hold has older content arriving
|
||||
// ABOVE it (the actual scroll-preservation scenario, not the at-top edge).
|
||||
await timeline.hover();
|
||||
let deepest = Number.POSITIVE_INFINITY;
|
||||
let stallStreak = 0;
|
||||
for (let attempt = 0; attempt < 120 && deepest > 250; attempt += 1) {
|
||||
await page.mouse.wheel(0, -4000);
|
||||
await page.waitForTimeout(70);
|
||||
const current = await oldestRenderedIndex();
|
||||
if (current !== null && current < deepest) {
|
||||
deepest = current;
|
||||
stallStreak = 0;
|
||||
} else {
|
||||
stallStreak += 1;
|
||||
if (stallStreak > 20) break;
|
||||
}
|
||||
}
|
||||
// Confirm phase 1 actually paginated into mid-history -- if it never climbed
|
||||
// off the newest window the rest of the test is meaningless.
|
||||
expect(deepest).toBeLessThan(400);
|
||||
|
||||
await page.waitForTimeout(100);
|
||||
const duringFetch = await timeline.evaluate((element) => {
|
||||
const timelineElement = element as HTMLDivElement;
|
||||
timelineElement.scrollTop = timelineElement.scrollTop + 160;
|
||||
timelineElement.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
return timelineElement.scrollTop;
|
||||
// PHASE 2 -- now delay the next history page so it stays in flight long
|
||||
// enough to observe the anchor across the landing. historyDelayMs is read
|
||||
// live by the bridge, so toggling it here applies to the next fetch only.
|
||||
await page.evaluate(() => {
|
||||
window.__BUZZ_E2E__ = {
|
||||
...window.__BUZZ_E2E__,
|
||||
mock: { ...window.__BUZZ_E2E__?.mock, historyDelayMs: 1_000 },
|
||||
};
|
||||
(
|
||||
window as unknown as { __HISTORY_INFLIGHT__?: number }
|
||||
).__HISTORY_INFLIGHT__ = 0;
|
||||
});
|
||||
expect(duringFetch).toBeGreaterThan(nearTop);
|
||||
const anchorDuringFetch = await getFirstVisibleMessage(page);
|
||||
expect(anchorDuringFetch).not.toBeNull();
|
||||
const inflightCount = () =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(window as unknown as { __HISTORY_INFLIGHT__?: number })
|
||||
.__HISTORY_INFLIGHT__ ?? 0,
|
||||
);
|
||||
|
||||
// Snapshot the oldest rendered index BEFORE firing the delayed page, so the
|
||||
// poll's growth gate can require a genuinely NEW older index after it lands.
|
||||
const oldestBeforeLanding = await oldestRenderedIndex();
|
||||
expect(oldestBeforeLanding).not.toBeNull();
|
||||
|
||||
// One wheel tick to fire the delayed older-history page.
|
||||
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||
if ((await inflightCount()) > 0) break;
|
||||
await page.mouse.wheel(0, -4000);
|
||||
await page.waitForTimeout(50);
|
||||
}
|
||||
expect(await inflightCount()).toBeGreaterThan(0);
|
||||
|
||||
// Capture the first-visible row id AFTER the fire wheel but WHILE the page is
|
||||
// still in flight (the prepend lands ~historyDelayMs later). The fire wheel
|
||||
// moves the viewport, so the anchor must be read at this settled in-flight
|
||||
// position -- a row captured before the fire wheel can scroll out of the
|
||||
// virtualized window before the prepend lands. This row exists before the
|
||||
// prepend and is the one the restore must hold.
|
||||
const anchorBeforeLanding = await getFirstVisibleMessage(page);
|
||||
expect(anchorBeforeLanding).not.toBeNull();
|
||||
|
||||
// The floating active-day header is pinned at the viewport top and reflects
|
||||
// the topmost visible row's day. As older history prepends ABOVE the anchor,
|
||||
// the header must neither move (it's pinned, not part of the prepended flow)
|
||||
// nor flip to a different day (the visible row's day is unchanged). Capture
|
||||
// its label + pinned offset alongside the anchor so the same landing that
|
||||
// proves the message anchor holds also proves the header holds.
|
||||
const headerBeforeLanding = await getActiveDayHeader(page);
|
||||
expect(headerBeforeLanding).not.toBeNull();
|
||||
|
||||
// The poll must observe the anchor holding AS a genuine older page lands
|
||||
// above it. It only counts a drift reading once BOTH hold in the same sample:
|
||||
// the delayed fetch has RESOLVED (inflight back to 0) AND it brought a real
|
||||
// older index that was not rendered before the prepend (proof the page was
|
||||
// not a duplicate-only fetch). Until then it returns Infinity and keeps
|
||||
// sampling, so it cannot pass against the settled pre-landing window.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const [anchor, metrics] = await Promise.all([
|
||||
getMessagePosition(page, anchorDuringFetch?.id ?? ""),
|
||||
getTimelineMetrics(page),
|
||||
const [inflight, oldestNow, anchor] = await Promise.all([
|
||||
inflightCount(),
|
||||
oldestRenderedIndex(),
|
||||
getMessagePosition(page, anchorBeforeLanding?.id ?? ""),
|
||||
]);
|
||||
if (metrics.scrollHeight <= beforeFetch.scrollHeight + 1000) {
|
||||
const landed =
|
||||
inflight === 0 &&
|
||||
oldestNow !== null &&
|
||||
oldestNow < (oldestBeforeLanding ?? 0);
|
||||
if (!landed || !anchor) {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
return anchor
|
||||
? Math.abs(anchor.top - (anchorDuringFetch?.top ?? 0))
|
||||
: Number.POSITIVE_INFINITY;
|
||||
return Math.abs(anchor.top - (anchorBeforeLanding?.top ?? 0));
|
||||
},
|
||||
{
|
||||
timeout: 3_000,
|
||||
},
|
||||
)
|
||||
.toBeLessThanOrEqual(2);
|
||||
|
||||
// The older page has now landed (the poll above only resolves on it). The
|
||||
// floating day-header must not have drifted or changed day across it.
|
||||
const headerAfterLanding = await getActiveDayHeader(page);
|
||||
expect(headerAfterLanding).not.toBeNull();
|
||||
expect(headerAfterLanding?.label).toBe(headerBeforeLanding?.label);
|
||||
expect(
|
||||
Math.abs((headerAfterLanding?.top ?? 0) - (headerBeforeLanding?.top ?? 0)),
|
||||
).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
// Criterion 2: abandon-to-bottom mid-fetch.
|
||||
@@ -329,8 +411,16 @@ test("does not teleport upward when user abandons fetch by jumping to bottom", a
|
||||
// Sanity: we did move away from the bottom and the prepend has NOT yet
|
||||
// resolved (otherwise the abandon scenario doesn't exist).
|
||||
expect(duringFetch.scrollTop).toBeLessThan(500);
|
||||
expect(duringFetch.scrollHeight).toBeLessThanOrEqual(
|
||||
baseline.scrollHeight + 100,
|
||||
// "Prepend not yet resolved" tested directly via the in-flight indicator the
|
||||
// suite already keys on (it renders while `isFetchingOlder` OR the prepended
|
||||
// rows have not painted yet — line 127 asserts its ABSENCE at idle load). The
|
||||
// older `scrollHeight <= baseline + 100` proxy assumed scrollHeight only
|
||||
// changes when DOM rows are added — true for a non-virtualized list, but a
|
||||
// virtualizer grows `getTotalSize()` purely from lazy row measurement (no
|
||||
// prepend), so that proxy false-fails here. This signal is agnostic to how
|
||||
// scrollHeight evolves.
|
||||
await expect(page.getByTestId("message-timeline-fetching-older")).toHaveCount(
|
||||
1,
|
||||
);
|
||||
|
||||
// Abandon: jump back to bottom while the fetch is still in flight.
|
||||
@@ -506,9 +596,13 @@ test("deep-link to a message in older history scrolls and highlights it", async
|
||||
// that sits well outside the initial-render window.
|
||||
const prependedIds: string[] = await page.evaluate(() => {
|
||||
for (let index = 0; index < 40; index += 1) {
|
||||
// Monotonic createdAt so `visible current 39` (seeded last) sorts to the
|
||||
// genuine last row rather than a random UUID-tiebreak position; matches
|
||||
// the channel-intro seed precedent. The :630 assertion stays untouched.
|
||||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "general",
|
||||
content: `visible current ${index}\nsecond line ${index}`,
|
||||
createdAt: 1_700_000_000 + index,
|
||||
});
|
||||
}
|
||||
const events = window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__?.({
|
||||
@@ -569,28 +663,75 @@ test("deep-link to a message in older history scrolls and highlights it", async
|
||||
const targetRow = timeline.locator(`[data-message-id="${targetId}"]`);
|
||||
await expect(targetRow).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// (b) Geometry: target row sits inside the timeline viewport AND near
|
||||
// the vertical center. "Near center" is defined as within one row-height
|
||||
// worth of slack of half the timeline's clientHeight -- generous enough
|
||||
// to tolerate centering implementation choices but tight enough to catch
|
||||
// "row is technically visible but at the top edge" regressions.
|
||||
// (b)/(c) Geometry + highlight. The convergence adapter re-aims the
|
||||
// virtualizer across several frames (scrollToIndex on an estimated index,
|
||||
// then re-resolved once rows measure), and only applies the highlight on
|
||||
// the settled frame via `onConverged`. The row therefore becomes DOM-
|
||||
// visible (passing `toBeVisible` above) at an intermediate overshoot
|
||||
// position one or more frames before it is centered and highlighted. We
|
||||
// poll the row's placement until it satisfies the full settled contract --
|
||||
// centered AND carrying the highlight class -- inside the 2s highlight-fade
|
||||
// window. A single synchronous read here would race the convergence loop.
|
||||
const placement = await timeline.evaluate((timelineEl, id) => {
|
||||
const t = timelineEl as HTMLDivElement;
|
||||
const row = t.querySelector<HTMLElement>(
|
||||
`[data-message-id="${CSS.escape(id)}"]`,
|
||||
);
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
const tRect = t.getBoundingClientRect();
|
||||
const rRect = row.getBoundingClientRect();
|
||||
return {
|
||||
rowTopRelative: rRect.top - tRect.top,
|
||||
rowBottomRelative: rRect.bottom - tRect.top,
|
||||
timelineHeight: tRect.height,
|
||||
rowHeight: rRect.height,
|
||||
className: row.className,
|
||||
};
|
||||
return new Promise<{
|
||||
rowTopRelative: number;
|
||||
rowBottomRelative: number;
|
||||
timelineHeight: number;
|
||||
rowHeight: number;
|
||||
className: string;
|
||||
} | null>((resolve) => {
|
||||
const deadline = performance.now() + 3_000;
|
||||
const tick = () => {
|
||||
const row = t.querySelector<HTMLElement>(
|
||||
`[data-message-id="${CSS.escape(id)}"]`,
|
||||
);
|
||||
if (row) {
|
||||
const tRect = t.getBoundingClientRect();
|
||||
const rRect = row.getBoundingClientRect();
|
||||
const result = {
|
||||
rowTopRelative: rRect.top - tRect.top,
|
||||
rowBottomRelative: rRect.bottom - tRect.top,
|
||||
timelineHeight: tRect.height,
|
||||
rowHeight: rRect.height,
|
||||
className: row.className,
|
||||
};
|
||||
const centered =
|
||||
Math.abs(
|
||||
(result.rowTopRelative + result.rowBottomRelative) / 2 -
|
||||
result.timelineHeight / 2,
|
||||
) <=
|
||||
result.timelineHeight / 2;
|
||||
if (
|
||||
centered &&
|
||||
result.className.includes("route-target-highlight-fade")
|
||||
) {
|
||||
resolve(result);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (performance.now() > deadline) {
|
||||
resolve(
|
||||
row
|
||||
? {
|
||||
rowTopRelative:
|
||||
row.getBoundingClientRect().top -
|
||||
t.getBoundingClientRect().top,
|
||||
rowBottomRelative:
|
||||
row.getBoundingClientRect().bottom -
|
||||
t.getBoundingClientRect().top,
|
||||
timelineHeight: t.getBoundingClientRect().height,
|
||||
rowHeight: row.getBoundingClientRect().height,
|
||||
className: row.className,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
tick();
|
||||
});
|
||||
}, targetId);
|
||||
expect(placement).not.toBeNull();
|
||||
const p = placement as NonNullable<typeof placement>;
|
||||
@@ -676,9 +817,13 @@ test("find-bar active match scrolls and highlights row regardless of position",
|
||||
} else if (i === bravoIndex) {
|
||||
body = `filler message ${i}\n${bravo}`;
|
||||
}
|
||||
// Monotonic createdAt so ALPHA/BRAVO land at their true sorted
|
||||
// positions (index 20 / 110) rather than random UUID-tiebreak slots;
|
||||
// matches the channel-intro seed precedent. Needle assertions untouched.
|
||||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "general",
|
||||
content: body,
|
||||
createdAt: 1_700_000_000 + i,
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -893,9 +1038,16 @@ test("composer expansion does not push bottom row out of viewport", async ({
|
||||
({ total, bottom }) => {
|
||||
for (let i = 0; i < total; i += 1) {
|
||||
const body = i === total - 1 ? bottom : `filler message ${i}`;
|
||||
// Monotonic createdAt: without it all rows share one whole-second
|
||||
// stamp and `sortMessages` tiebreaks by random UUID, so BOTTOM_NEEDLE
|
||||
// (seeded last) lands at a random sorted index — often outside the
|
||||
// virtualized window, so `toContainText(BOTTOM_NEEDLE)` flakes. A
|
||||
// distinct stamp per row sorts the needle to the true last row.
|
||||
// Matches the channel-intro seed precedent (1_700_000_001 + index).
|
||||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "general",
|
||||
content: body,
|
||||
createdAt: 1_700_000_000 + i,
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -1019,9 +1171,13 @@ test("in-viewport reflow above the anchor row does not push it down", async ({
|
||||
// rows above whatever we anchor to.
|
||||
await page.evaluate(() => {
|
||||
for (let index = 0; index < 60; index += 1) {
|
||||
// Monotonic createdAt so `resize-anchor row 59` (seeded last) sorts to
|
||||
// the genuine last row rather than a random UUID-tiebreak position; see
|
||||
// the composer seed for the full rationale.
|
||||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "general",
|
||||
content: `resize-anchor row ${index}\nsecond line ${index}\nthird line ${index}`,
|
||||
createdAt: 1_700_000_000 + index,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -175,4 +175,134 @@ test.describe("list virtualization", () => {
|
||||
// content-visibility invariant the divergence rests on (no unmount).
|
||||
await expect(headers).toHaveCount(2);
|
||||
});
|
||||
|
||||
test("07 — load-older prepend holds the anchored row without jitter or reconcile spin", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Install once: addInitScript re-runs on every navigation in this page, so
|
||||
// each page.goto in the loop below re-applies the mock bridge.
|
||||
await installMockBridge(page);
|
||||
|
||||
// The deep-history channel seeds 600 messages; the initial load windows to
|
||||
// the newest 200, leaving 400 older behind the until cursor — enough that
|
||||
// every run lands a genuine prepend. Reads the first row at/below the
|
||||
// viewport top and returns scrollTop, scrollHeight, and that row's on-screen
|
||||
// VIEWPORT position in ONE settled snapshot — the position the single-writer
|
||||
// restore must hold steady across the prepend.
|
||||
//
|
||||
// Waits inside the browser for a measurement-settled frame before reading.
|
||||
// The virtualizer re-windows after a scroll: for a few rAFs the mounted rows
|
||||
// can all sit above the viewport top (their absolute offsets lag the new
|
||||
// scrollTop) until the library mounts rows at the current position. That is
|
||||
// a measurement transient, NOT the scrollTop race — scrollTop is already
|
||||
// correct on those frames. Reading on such a frame would throw "no row";
|
||||
// polling for a settled frame removes the flake without touching any
|
||||
// race-detection threshold below (scrollTop value + viewportPos stability),
|
||||
// and snapshots all three fields together so they can't skew across reads.
|
||||
const sampleAnchor = (timeline: Locator) =>
|
||||
timeline.evaluate(async (scroller) => {
|
||||
const s = scroller as HTMLElement;
|
||||
for (let frame = 0; frame < 60; frame += 1) {
|
||||
const scrollerTop = s.getBoundingClientRect().top;
|
||||
const row = Array.from(
|
||||
s.querySelectorAll<HTMLElement>("[data-message-id]"),
|
||||
).find((r) => r.getBoundingClientRect().top - scrollerTop >= 0);
|
||||
if (row) {
|
||||
return {
|
||||
viewportPos: row.getBoundingClientRect().top - scrollerTop,
|
||||
scrollTop: s.scrollTop,
|
||||
scrollHeight: s.scrollHeight,
|
||||
};
|
||||
}
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
}
|
||||
throw new Error("no anchor row mounted after 60 frames");
|
||||
});
|
||||
|
||||
// Determinism is the bar, not pass-once. The original defect was a RACE: a
|
||||
// second restore loop (the resize-observer restoring to the pre-fetch
|
||||
// scrollTop of 0, fired by the load-older spinner's clientHeight shift)
|
||||
// fought the anchor restore frame-by-frame; last writer won, so the anchor
|
||||
// held only ~2 of 3 runs and on its losing runs scrollTop collapsed to ~0
|
||||
// (view stuck at the top, anchor lost). A single prepend can go green on a
|
||||
// lucky scheduling order, so this drives the prepend on SIX fresh page loads
|
||||
// and asserts the anchor holds on every one — a flaky-pass fails the run.
|
||||
// Fresh navigation each iteration resets the virtualizer's measurement state,
|
||||
// matching the run-to-run conditions under which the race surfaced.
|
||||
for (let run = 0; run < 6; run += 1) {
|
||||
// Force a full document reload each iteration. Navigating straight to the
|
||||
// same hash route is a same-document hash change, not a reload, so the
|
||||
// virtualizer + paginated history would carry over and later runs would
|
||||
// exhaust the older pages — defeating the per-run fresh-prepend premise.
|
||||
await page.goto("about:blank");
|
||||
await page.goto("/#/channels/feedf00d-0000-4000-8000-000000000007");
|
||||
const timeline = page.getByTestId("message-timeline");
|
||||
await expect(timeline).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-message-id^="mock-deep-history-"]').first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Scroll up to mount mid-history rows while staying clear of the load-older
|
||||
// sentinel zone (trips within 200px of the top), then let the windowed rows
|
||||
// measure off their 80px estimate so the pre-prepend anchor reading is
|
||||
// stable. The single trigger is the deliberate scrollTop = 0 below.
|
||||
await timeline.evaluate((el) => {
|
||||
el.scrollTop = 4000;
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
await timeline.evaluate((el) => {
|
||||
el.scrollTop = 4000;
|
||||
});
|
||||
await page.waitForTimeout(150);
|
||||
const before = await sampleAnchor(timeline);
|
||||
expect(before.scrollTop).toBeGreaterThan(200);
|
||||
|
||||
// Trigger exactly one prepend. Scrolling to 150 trips the load-older
|
||||
// sentinel (its rootMargin reaches 200px past the top) with
|
||||
// previousScrollTopRef pinned near the top — the condition under which the
|
||||
// resize-observer's competing restore collapsed the anchor pre-fix. After
|
||||
// the single fetchOlder lands, the anchor restore carries scrollTop deep
|
||||
// into the content, clear of the 200px sentinel zone, so the observer does
|
||||
// NOT re-fire: one clean prepend, not the re-trigger storm that scrollTop
|
||||
// 0 produces (0 keeps the sentinel tripped across every paged window down
|
||||
// to the small exhaustion-tail page, which legitimately lands the top row
|
||||
// near the top — masking the hold signal).
|
||||
await timeline.evaluate((el) => {
|
||||
el.scrollTop = 150;
|
||||
});
|
||||
|
||||
// Anchor-hold gate (the race signal): poll until the restore has carried
|
||||
// scrollTop deep into the content — past where it sat before the prepend.
|
||||
// Pre-fix, the competing resize-observer restore (firing on the spinner's
|
||||
// clientHeight shift, restoring to previousScrollTopRef ~150) won often
|
||||
// enough that scrollTop stayed pinned near the top; this poll would then
|
||||
// time out, failing the run. scrollHeight grows several frames BEFORE the
|
||||
// restore moves scrollTop, so a scrollHeight gate would read mid-cycle
|
||||
// near the top — the race lives in scrollTop, so the gate watches it.
|
||||
await expect
|
||||
.poll(async () => (await sampleAnchor(timeline)).scrollTop, {
|
||||
timeout: 10_000,
|
||||
})
|
||||
.toBeGreaterThan(before.scrollTop);
|
||||
|
||||
// One settled snapshot for the remaining checks so scrollHeight and
|
||||
// viewportPos come from the same frame as the held scrollTop:
|
||||
// (a) the scroller grew by the prepended rows' height (genuine prepend),
|
||||
// (b) the first-visible row sits where it did before the prepend.
|
||||
const after = await sampleAnchor(timeline);
|
||||
expect(after.scrollHeight).toBeGreaterThan(before.scrollHeight + 800);
|
||||
expect(Math.abs(after.viewportPos - before.viewportPos)).toBeLessThan(
|
||||
120,
|
||||
);
|
||||
|
||||
// Reconcile terminates: two equal scrollTop reads 600ms apart prove the
|
||||
// rAF loop stopped. Under the double-writer bug the library re-scheduled
|
||||
// one rAF per frame for the full 5s MAX_RECONCILE_MS valve — still churning
|
||||
// 600ms apart.
|
||||
const settled1 = await timeline.evaluate((el) => el.scrollTop);
|
||||
await page.waitForTimeout(600);
|
||||
const settled2 = await timeline.evaluate((el) => el.scrollTop);
|
||||
expect(Math.abs(settled1 - settled2)).toBeLessThan(2);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user