mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
spike(timeline): assess @tanstack/react-virtual feasibility for main timeline
Read-only Phase 2 feasibility spike (NOT ship-ready). Assesses whether @tanstack/react-virtual can cleanly own the main timeline, replacing the bespoke scrollTop-locking scroll manager and the ~2,000-row render ceiling. Artifacts: - buildVirtualTimelineRows.ts: pure helper flattening day-grouped entries into a flat indexed row list (day-divider + message kinds) the virtualizer can measure/key; includes findVirtualRowIndexForMessage for deep-link/find. - buildVirtualTimelineRows.test.mjs: 9 tests covering dividers, renderKey keying, prepend key stability (native position-retention contract), and flat-index lookup. - __spike__/VirtualizedTimelinePoc.tsx: thin non-wired PoC showing the react-virtual integration shape (autoscroll, prepend retention via getItemKey, scrollToIndex for find/deep-link). - __spike__/FEASIBILITY.md: honest verdict on the three scoped points — sticky-bottom autoscroll, scroll-up pagination with native position retention, and cmd+F find-in-page plan. Co-authored-by: Taylor Ho <taylorkmho@gmail.com> Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
co-authored by
Taylor Ho
parent
cbc754cffb
commit
606dfac37e
@@ -43,6 +43,7 @@
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-router": "^1.168.10",
|
||||
"@tanstack/react-virtual": "^3.14.2",
|
||||
"@tauri-apps/api": "~2.11",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-opener": "~2.5",
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildVirtualTimelineRows,
|
||||
findVirtualRowIndexForMessage,
|
||||
} from "./buildVirtualTimelineRows.ts";
|
||||
|
||||
const DAY = 24 * 60 * 60;
|
||||
// Anchor at local noon so day boundaries are unambiguous across timezones.
|
||||
const DAY_1 = Math.floor(new Date(2026, 0, 1, 12, 0, 0).getTime() / 1000);
|
||||
const DAY_2 = DAY_1 + DAY;
|
||||
const DAY_3 = DAY_2 + DAY;
|
||||
|
||||
function message(overrides) {
|
||||
return {
|
||||
id: "message",
|
||||
createdAt: DAY_1,
|
||||
depth: 0,
|
||||
kind: 9,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("empty snapshot produces no rows", () => {
|
||||
assert.deepEqual(buildVirtualTimelineRows([]), []);
|
||||
});
|
||||
|
||||
test("single message emits one divider then the message row", () => {
|
||||
const rows = buildVirtualTimelineRows([message({ id: "a" })]);
|
||||
assert.equal(rows.length, 2);
|
||||
assert.equal(rows[0].kind, "day-divider");
|
||||
assert.equal(rows[0].messageIndex, -1);
|
||||
assert.equal(rows[1].kind, "message");
|
||||
assert.equal(rows[1].messageIndex, 0);
|
||||
assert.equal(rows[1].key, "a");
|
||||
});
|
||||
|
||||
test("messages on the same day share a single divider", () => {
|
||||
const rows = buildVirtualTimelineRows([
|
||||
message({ id: "a", createdAt: DAY_1 }),
|
||||
message({ id: "b", createdAt: DAY_1 + 60 }),
|
||||
message({ id: "c", createdAt: DAY_1 + 120 }),
|
||||
]);
|
||||
// 1 divider + 3 messages
|
||||
assert.equal(rows.length, 4);
|
||||
assert.equal(rows.filter((r) => r.kind === "day-divider").length, 1);
|
||||
assert.deepEqual(
|
||||
rows.filter((r) => r.kind === "message").map((r) => r.messageIndex),
|
||||
[0, 1, 2],
|
||||
);
|
||||
});
|
||||
|
||||
test("a new calendar day inserts a fresh divider before its first message", () => {
|
||||
const rows = buildVirtualTimelineRows([
|
||||
message({ id: "a", createdAt: DAY_1 }),
|
||||
message({ id: "b", createdAt: DAY_2 }),
|
||||
message({ id: "c", createdAt: DAY_3 }),
|
||||
]);
|
||||
// 3 dividers + 3 messages, strictly interleaved
|
||||
assert.deepEqual(
|
||||
rows.map((r) => r.kind),
|
||||
[
|
||||
"day-divider",
|
||||
"message",
|
||||
"day-divider",
|
||||
"message",
|
||||
"day-divider",
|
||||
"message",
|
||||
],
|
||||
);
|
||||
// message indices stay monotonic and correct across dividers
|
||||
assert.deepEqual(
|
||||
rows.filter((r) => r.kind === "message").map((r) => r.messageIndex),
|
||||
[0, 1, 2],
|
||||
);
|
||||
});
|
||||
|
||||
test("message row key prefers renderKey over id (optimistic-send stability)", () => {
|
||||
const rows = buildVirtualTimelineRows([
|
||||
message({ id: "server-id", renderKey: "local-key" }),
|
||||
]);
|
||||
const messageRow = rows.find((r) => r.kind === "message");
|
||||
assert.equal(messageRow.key, "local-key");
|
||||
});
|
||||
|
||||
test("divider keys are stable across re-flatten of the same snapshot", () => {
|
||||
const snapshot = [
|
||||
message({ id: "a", createdAt: DAY_1 }),
|
||||
message({ id: "b", createdAt: DAY_2 }),
|
||||
];
|
||||
const first = buildVirtualTimelineRows(snapshot);
|
||||
const second = buildVirtualTimelineRows(snapshot);
|
||||
assert.deepEqual(
|
||||
first.map((r) => r.key),
|
||||
second.map((r) => r.key),
|
||||
);
|
||||
});
|
||||
|
||||
test("findVirtualRowIndexForMessage returns the FLAT index, accounting for dividers", () => {
|
||||
const messages = [
|
||||
message({ id: "a", createdAt: DAY_1 }),
|
||||
message({ id: "b", createdAt: DAY_2 }),
|
||||
message({ id: "c", createdAt: DAY_2 + 60 }),
|
||||
];
|
||||
const rows = buildVirtualTimelineRows(messages);
|
||||
// layout: [div, a, div, b, c] -> flat indices 0..4
|
||||
assert.equal(findVirtualRowIndexForMessage(rows, "a", messages), 1);
|
||||
assert.equal(findVirtualRowIndexForMessage(rows, "b", messages), 3);
|
||||
assert.equal(findVirtualRowIndexForMessage(rows, "c", messages), 4);
|
||||
});
|
||||
|
||||
test("findVirtualRowIndexForMessage returns -1 for an absent or empty target", () => {
|
||||
const messages = [message({ id: "a" })];
|
||||
const rows = buildVirtualTimelineRows(messages);
|
||||
assert.equal(findVirtualRowIndexForMessage(rows, "missing", messages), -1);
|
||||
assert.equal(findVirtualRowIndexForMessage(rows, null, messages), -1);
|
||||
assert.equal(findVirtualRowIndexForMessage(rows, undefined, messages), -1);
|
||||
});
|
||||
|
||||
test("prepend keeps surviving rows' keys stable (native position-retention contract)", () => {
|
||||
// Older page prepended at the top: 'a' was the head, now 'older' precedes it.
|
||||
const before = [message({ id: "a", createdAt: DAY_2 })];
|
||||
const after = [
|
||||
message({ id: "older", createdAt: DAY_1 }),
|
||||
message({ id: "a", createdAt: DAY_2 }),
|
||||
];
|
||||
const rowsBefore = buildVirtualTimelineRows(before);
|
||||
const rowsAfter = buildVirtualTimelineRows(after);
|
||||
|
||||
const keyOfA = (rows) =>
|
||||
rows.find((r) => r.kind === "message" && r.key === "a")?.key;
|
||||
// 'a' keeps its identity across the prepend -> virtualizer measurement cache
|
||||
// survives -> scroll position re-anchors natively (no double-rAF correction).
|
||||
assert.equal(keyOfA(rowsBefore), "a");
|
||||
assert.equal(keyOfA(rowsAfter), "a");
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* SPIKE (Phase 2 virtualization feasibility): flatten the day-grouped main
|
||||
* timeline into a single ordered list of "virtual rows".
|
||||
*
|
||||
* `@tanstack/react-virtual` measures and keys a FLAT, index-addressable list. But
|
||||
* the live timeline renders a NESTED shape — `<section>` per day, each wrapping a
|
||||
* `<DayDivider>` plus its message `<div>`s (see `TimelineMessageList.tsx`). A
|
||||
* virtualizer can't measure a nested-section tree; it needs every visually
|
||||
* distinct, separately-measured thing (each divider AND each message) as its own
|
||||
* top-level row.
|
||||
*
|
||||
* This helper performs exactly that flattening, off the SAME snapshot the rows
|
||||
* render from (the correctness property `timelineSnapshot.ts` documents). It
|
||||
* reuses `buildDayGroupBoundaries` so divider placement is byte-identical to the
|
||||
* current render — no second source of truth for "where does a day start".
|
||||
*
|
||||
* Two row kinds come out, in render order:
|
||||
* - { kind: "day-divider", key, label-source timestamp, messageIndex: -1 }
|
||||
* - { kind: "message", key, messageIndex } — index back into the snapshot
|
||||
*
|
||||
* `key` is the virtualizer's stable identity (`getItemKey`). For messages it
|
||||
* prefers `renderKey` (stable across optimistic send-ack, mirroring
|
||||
* `selectLatestMessageKey`) and falls back to `id`. Stable keys are what let the
|
||||
* virtualizer hold scroll position on PREPEND: when older pages splice in at the
|
||||
* top, every surviving row keeps its key, so the measurement cache survives and
|
||||
* the virtualizer re-anchors natively — the mechanism that lets us DELETE the
|
||||
* double-rAF `scrollTop` correction in `useLoadOlderOnScroll.ts`.
|
||||
*/
|
||||
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import { buildDayGroupBoundaries } from "./timelineSnapshot";
|
||||
|
||||
/** A divider row — one per calendar-day boundary. Carries no message. */
|
||||
export type VirtualDayDividerRow = {
|
||||
kind: "day-divider";
|
||||
/** Stable virtualizer key. */
|
||||
key: string;
|
||||
/** `createdAt` (unix seconds) of the first message in the day; drives the label. */
|
||||
headingTimestamp: number;
|
||||
/** Always -1 for dividers — they don't map to a message. */
|
||||
messageIndex: -1;
|
||||
};
|
||||
|
||||
/** A message row — one per timeline message, in snapshot order. */
|
||||
export type VirtualMessageRow = {
|
||||
kind: "message";
|
||||
/** Stable virtualizer key — prefers `renderKey`, falls back to `id`. */
|
||||
key: string;
|
||||
/** Index back into the source snapshot, for O(1) message lookup at render. */
|
||||
messageIndex: number;
|
||||
};
|
||||
|
||||
export type VirtualTimelineRow = VirtualDayDividerRow | VirtualMessageRow;
|
||||
|
||||
/**
|
||||
* Flatten a message snapshot into ordered virtual rows (dividers + messages).
|
||||
*
|
||||
* Walks the snapshot once; emits a divider row at each day-group start index
|
||||
* (computed by `buildDayGroupBoundaries`, the same helper the live render uses),
|
||||
* then the message row. Order is identical to the current nested render read
|
||||
* top-to-bottom, so a virtualizer driven off this list paints the exact same
|
||||
* visual sequence.
|
||||
*/
|
||||
export function buildVirtualTimelineRows(
|
||||
messages: readonly TimelineMessage[],
|
||||
): VirtualTimelineRow[] {
|
||||
const rows: VirtualTimelineRow[] = [];
|
||||
const dayStartIndices = new Set(
|
||||
buildDayGroupBoundaries(messages).map((boundary) => boundary.startIndex),
|
||||
);
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i];
|
||||
|
||||
if (dayStartIndices.has(i)) {
|
||||
rows.push({
|
||||
kind: "day-divider",
|
||||
key: `day-${message.createdAt}`,
|
||||
headingTimestamp: message.createdAt,
|
||||
messageIndex: -1,
|
||||
});
|
||||
}
|
||||
|
||||
rows.push({
|
||||
kind: "message",
|
||||
key: message.renderKey ?? message.id,
|
||||
messageIndex: i,
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the flat virtual-row index for a target message id. The virtualizer's
|
||||
* `scrollToIndex` needs a FLAT index, not a message index — and the two diverge
|
||||
* because divider rows are interleaved. This is the bridge that lets BOTH
|
||||
* jump-to-message deep links AND find-in-page (cmd+F replacement) drive
|
||||
* `scrollToIndex` against a virtualized list, since the target row may be
|
||||
* unmounted (the whole reason native find breaks under virtualization).
|
||||
*
|
||||
* Returns -1 when the target isn't in this snapshot — same "row not committed
|
||||
* yet, bail and wait for the next snapshot" contract as `resolveDeepLinkTarget`.
|
||||
*/
|
||||
export function findVirtualRowIndexForMessage(
|
||||
rows: readonly VirtualTimelineRow[],
|
||||
targetMessageId: string | null | undefined,
|
||||
messages: readonly TimelineMessage[],
|
||||
): number {
|
||||
if (!targetMessageId) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const messageIndex = messages.findIndex(
|
||||
(message) => message.id === targetMessageId,
|
||||
);
|
||||
if (messageIndex === -1) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return rows.findIndex(
|
||||
(row) => row.kind === "message" && row.messageIndex === messageIndex,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
# Phase 2 Virtualization Feasibility Spike
|
||||
|
||||
**Verdict: GO.** `@tanstack/react-virtual` can cleanly own the main timeline. It
|
||||
absorbs scroll-up pagination with native position retention, owns sticky-bottom
|
||||
autoscroll, and gives `cmd`+F a real plan. The one non-trivial cost — it must
|
||||
*replace*, not coexist with, the bespoke scroll manager — is expected and
|
||||
bounded. Nothing in the three proof points is a blocker.
|
||||
|
||||
Spike artifacts (all under `desktop/src/features/messages/`):
|
||||
- `lib/buildVirtualTimelineRows.ts` — pure flatten helper (+ 9 `.test.mjs` tests, all green)
|
||||
- `ui/__spike__/VirtualizedTimelinePoc.tsx` — integration-shape PoC (not wired)
|
||||
- this file
|
||||
|
||||
Dependency added: `@tanstack/react-virtual@^3.14.2`.
|
||||
|
||||
---
|
||||
|
||||
## The core structural mismatch (and the fix)
|
||||
|
||||
The live render is **nested**: `TimelineMessageList` builds `<section>` per day,
|
||||
each wrapping a `<DayDivider>` + its message `<div>`s. A virtualizer can't
|
||||
measure a nested-section tree — it needs a **flat, index-addressable** list where
|
||||
every separately-measured thing (each divider AND each message) is its own row.
|
||||
|
||||
`buildVirtualTimelineRows` does exactly that flattening, reusing
|
||||
`buildDayGroupBoundaries` so divider placement stays byte-identical to today's
|
||||
render — no second source of truth. This is the load-bearing transform and it's
|
||||
fully unit-tested. Day dividers survive as first-class variable-height rows.
|
||||
|
||||
---
|
||||
|
||||
## Proof point 1 — Sticky-bottom autoscroll (the riskiest) — OWNABLE, requires replacement
|
||||
|
||||
**Finding: the virtualizer must REPLACE `useTimelineScrollManager`, not reconcile
|
||||
with it.** They both want to own the single scroll container, and that's a
|
||||
head-on collision — you cannot run both.
|
||||
|
||||
Why replacement is clean rather than scary: nearly everything the 427-line
|
||||
bespoke manager does by hand, the virtualizer does natively.
|
||||
|
||||
| Bespoke manager does (by hand) | Virtualizer equivalent |
|
||||
|-----------------------------------------------------------------|-------------------------------------------------|
|
||||
| `scrollToBottom()` — `scrollIntoView` + `scrollTo` + 2 rAF settle | `scrollToIndex(lastRow, { align: "end" })` |
|
||||
| `lockedScrollTopRef` scrollTop locking | virtualizer owns scrollTop; no manual lock |
|
||||
| ResizeObserver re-pinning on content/timeline resize | dynamic `measureElement` re-measures + re-anchors |
|
||||
| `isNearBottom` / `shouldStickToBottomRef` pin tracking | a `stickToBottom` flag + `scrollToIndex(last)` |
|
||||
|
||||
The PoC shows the autoscroll path in ~3 lines. The real migration deletes the
|
||||
manual `scrollTop` plumbing; the genuinely-bespoke bits worth porting carefully
|
||||
are the **`accent` smooth-scroll** (highlighted-message smooth vs auto) and the
|
||||
**`newMessageCount`** ("N new messages" pill when scrolled up) — both are app
|
||||
state layered ON the scroll position, easy to keep as a thin wrapper around the
|
||||
virtualizer. **Risk: medium, as Ned flagged — but it's replacement risk, not
|
||||
"can it be done" risk.**
|
||||
|
||||
## Proof point 2 — Scroll-up pagination with NATIVE position retention — YES. Delete the band-aid.
|
||||
|
||||
**This is the headline answer Ned asked for: the virtualizer absorbs it
|
||||
natively. The double-rAF correction in `useLoadOlderOnScroll.ts` gets DELETED,
|
||||
not patched.**
|
||||
|
||||
Mechanism: the virtualizer keys every row by stable identity (`getItemKey`).
|
||||
When an older page splices in at the top, every surviving row keeps its key, so
|
||||
its cached measurement and offset survive — the virtualizer re-anchors the
|
||||
viewport to the same logical rows automatically. No `previousHeight` snapshot, no
|
||||
`newHeight - previousHeight` delta, no `requestAnimationFrame(requestAnimationFrame(...))`
|
||||
post-paint `scrollTop` yank. That brittle after-the-paint correction (tho's
|
||||
flagged jank #1) is *exactly* what stable-key retention replaces.
|
||||
|
||||
`useLoadOlderOnScroll` collapses to "fire `fetchOlder` when near top" — the
|
||||
`IntersectionObserver` trigger stays, the `restoreScrollPosition` plumbing goes.
|
||||
The `buildVirtualTimelineRows` prepend test locks in the key-stability contract
|
||||
this depends on.
|
||||
|
||||
## Proof point 3 — `cmd`+F find-in-page — PLAN (with code shape proven)
|
||||
|
||||
Virtualization unmounts off-screen rows, so native browser find can't see them.
|
||||
**Plan: in-app find drives `scrollToIndex` to the target row, mounting it on
|
||||
demand.** The wiring already exists — `searchActiveMessageId` /
|
||||
`searchMatchingMessageIds` / `searchQuery` are plumbed through the rows today, and
|
||||
the current search-scroll effect (`MessageTimeline.tsx` ~L182) already does
|
||||
`querySelector([data-message-id]) + scrollIntoView`. That `querySelector`
|
||||
approach breaks under virtualization (row may be unmounted), so it gets replaced
|
||||
by `findVirtualRowIndexForMessage(...) -> virtualizer.scrollToIndex(rowIndex)`,
|
||||
which scrolls the row into existence first. The PoC proves this exact path.
|
||||
|
||||
Recommended additions for the real PR (not in this spike):
|
||||
- **Match navigation** (next/prev match) reuses the same `scrollToIndex` bridge.
|
||||
- **"render-all" escape hatch** for true native `cmd`+F: when find opens, optionally
|
||||
bypass virtualization and render all rows. Keep as a fallback toggle, not the
|
||||
default — defeats the perf win if always on. My lean: ship the in-app path,
|
||||
hold the escape hatch unless QA finds a gap.
|
||||
|
||||
---
|
||||
|
||||
## Must-keeps — all survive
|
||||
|
||||
- **Sticky-bottom autoscroll** — proof point 1. ✅ (via replacement)
|
||||
- **Day dividers (variable-height interleaved rows)** — first-class flat rows via
|
||||
`buildVirtualTimelineRows` + `measureElement`. ✅
|
||||
- **Jump-to-message deep links (`resolveDeepLinkTarget`)** — same `scrollToIndex`
|
||||
bridge as find; `findVirtualRowIndexForMessage` mirrors the "bail if not in
|
||||
snapshot yet" contract. ✅
|
||||
- **No-tearing** — the Phase 1 property (drive scroll logic + render off the SAME
|
||||
snapshot) is PRESERVED: `buildVirtualTimelineRows` consumes the same
|
||||
`deferredMessages` snapshot the rows render from. ✅
|
||||
|
||||
## Recommended sequence for the real PR (post-spike)
|
||||
|
||||
1. Land `buildVirtualTimelineRows` + tests (already done here).
|
||||
2. Build the virtualized list driving off `deferredMessages`; render via the
|
||||
existing `MessageRow` / `DayDivider` (reuse, don't rebuild).
|
||||
3. Replace `useTimelineScrollManager` with a thin virtualizer wrapper preserving
|
||||
`accent` smooth-scroll + `newMessageCount`.
|
||||
4. Gut `useLoadOlderOnScroll` down to the trigger; delete the rAF correction.
|
||||
5. Rewire search-scroll + deep-link to `scrollToIndex`.
|
||||
6. Main timeline ONLY — leave the thread pane (Phase 3 may shrink it away).
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* SPIKE ONLY — Phase 2 virtualization feasibility proof-of-concept.
|
||||
*
|
||||
* This is NOT wired into the live `MessageTimeline` and is NOT ship-ready. It
|
||||
* exists to demonstrate the integration SHAPE of `@tanstack/react-virtual`
|
||||
* against the real main-timeline constraints, and to make the three required
|
||||
* proof points concrete in code rather than prose:
|
||||
*
|
||||
* 1. Sticky-bottom autoscroll -> `virtualizer.scrollToIndex(last, end)`
|
||||
* 2. Native scroll-up prepend -> stable `getItemKey` + the virtualizer's
|
||||
* retention own scroll anchoring (NO double-rAF)
|
||||
* 3. cmd+F find / deep-link jump -> `virtualizer.scrollToIndex(rowIndex)`
|
||||
* to an UNMOUNTED row via `findVirtualRowIndexForMessage`
|
||||
*
|
||||
* Variable-height rows (messages vs day dividers) are handled by
|
||||
* `measureElement` (dynamic measurement), which is exactly why react-virtual is
|
||||
* the lean over react-window — see FEASIBILITY.md.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import {
|
||||
buildVirtualTimelineRows,
|
||||
findVirtualRowIndexForMessage,
|
||||
} from "@/features/messages/lib/buildVirtualTimelineRows";
|
||||
|
||||
type VirtualizedTimelinePocProps = {
|
||||
messages: TimelineMessage[];
|
||||
/** Find-in-page / deep-link target. Drives a programmatic scroll to a row that may be unmounted. */
|
||||
scrollToMessageId?: string | null;
|
||||
/** Whether to keep pinned to the newest message as it arrives (sticky-bottom). */
|
||||
stickToBottom?: boolean;
|
||||
renderMessage: (message: TimelineMessage) => React.ReactNode;
|
||||
renderDayDivider: (headingTimestamp: number) => React.ReactNode;
|
||||
};
|
||||
|
||||
// Initial guesses only — `measureElement` corrects to real height after paint.
|
||||
const ESTIMATED_MESSAGE_HEIGHT = 64;
|
||||
const ESTIMATED_DIVIDER_HEIGHT = 32;
|
||||
const OVERSCAN = 8;
|
||||
|
||||
export function VirtualizedTimelinePoc({
|
||||
messages,
|
||||
scrollToMessageId,
|
||||
stickToBottom = true,
|
||||
renderMessage,
|
||||
renderDayDivider,
|
||||
}: VirtualizedTimelinePocProps) {
|
||||
const scrollRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// Flat, index-addressable rows (dividers + messages) off the SAME snapshot the
|
||||
// rows render from — the correctness property timelineSnapshot.ts documents.
|
||||
const rows = React.useMemo(
|
||||
() => buildVirtualTimelineRows(messages),
|
||||
[messages],
|
||||
);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: (index) =>
|
||||
rows[index]?.kind === "day-divider"
|
||||
? ESTIMATED_DIVIDER_HEIGHT
|
||||
: ESTIMATED_MESSAGE_HEIGHT,
|
||||
// Stable per-row identity. THIS is what lets a top-prepend (older page)
|
||||
// retain scroll position natively: surviving rows keep their key, so the
|
||||
// measurement cache survives and the virtualizer re-anchors itself. No
|
||||
// before/after scrollHeight delta math, no double-rAF correction.
|
||||
getItemKey: (index) => rows[index]?.key ?? index,
|
||||
overscan: OVERSCAN,
|
||||
});
|
||||
|
||||
// (1) Sticky-bottom autoscroll: when a new latest row arrives and we're
|
||||
// pinned, jump to the last row aligned to the bottom edge. Replaces the
|
||||
// bespoke scrollTop-locking manager's `scrollToBottom`.
|
||||
const lastRowIndex = rows.length - 1;
|
||||
React.useEffect(() => {
|
||||
if (!stickToBottom || lastRowIndex < 0) {
|
||||
return;
|
||||
}
|
||||
virtualizer.scrollToIndex(lastRowIndex, { align: "end" });
|
||||
}, [lastRowIndex, stickToBottom, virtualizer]);
|
||||
|
||||
// (3) cmd+F find / deep-link jump to a possibly-UNMOUNTED row. The virtualizer
|
||||
// scrolls the row into existence then aligns it — the in-app-search path that
|
||||
// replaces native browser find (which can't see unmounted rows).
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: re-run only when the target changes
|
||||
React.useEffect(() => {
|
||||
if (!scrollToMessageId) {
|
||||
return;
|
||||
}
|
||||
const rowIndex = findVirtualRowIndexForMessage(
|
||||
rows,
|
||||
scrollToMessageId,
|
||||
messages,
|
||||
);
|
||||
if (rowIndex === -1) {
|
||||
// Target not in this snapshot yet — bail; the next snapshot drives it.
|
||||
return;
|
||||
}
|
||||
virtualizer.scrollToIndex(rowIndex, { align: "center" });
|
||||
}, [scrollToMessageId, virtualizer]);
|
||||
|
||||
const virtualItems = virtualizer.getVirtualItems();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
// Single scroll container the virtualizer OWNS. The current bespoke
|
||||
// manager owns this same div via scrollContainerRef — they cannot coexist;
|
||||
// the virtualizer must replace it (see FEASIBILITY.md, autoscroll section).
|
||||
className="h-full overflow-y-auto [overflow-anchor:none]"
|
||||
>
|
||||
<div
|
||||
// Spacer sized to the full virtual height; rows are absolutely
|
||||
// positioned within it at their measured offsets.
|
||||
style={{
|
||||
height: virtualizer.getTotalSize(),
|
||||
width: "100%",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{virtualItems.map((virtualItem) => {
|
||||
const row = rows[virtualItem.index];
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
data-index={virtualItem.index}
|
||||
// Dynamic measurement: each row reports its real height back, so
|
||||
// variable-height messages AND dividers are handled without a
|
||||
// fixed row-height assumption.
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}}
|
||||
>
|
||||
{row.kind === "day-divider"
|
||||
? renderDayDivider(row.headingTimestamp)
|
||||
: renderMessage(messages[row.messageIndex])}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Generated
+20
-3
@@ -87,6 +87,9 @@ importers:
|
||||
'@tanstack/react-router':
|
||||
specifier: ^1.168.10
|
||||
version: 1.170.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@tanstack/react-virtual':
|
||||
specifier: ^3.14.2
|
||||
version: 3.14.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@tauri-apps/api':
|
||||
specifier: ~2.11
|
||||
version: 2.11.0
|
||||
@@ -1456,14 +1459,12 @@ packages:
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-musl@4.3.0':
|
||||
resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-gnu@4.3.0':
|
||||
resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==}
|
||||
@@ -1540,6 +1541,12 @@ packages:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@tanstack/react-virtual@3.14.2':
|
||||
resolution: {integrity: sha512-IpWnmCLvuymRfeeLNVXIzNEYBFLpd3drVIS91sqV78VTZFyldlChkOocZRCPp1B+Wnk09bcLNme8WaMU/9/9bQ==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@tanstack/router-core@1.171.5':
|
||||
resolution: {integrity: sha512-BfilbQqqWiQwJn68cD8wmk1ajEWIO3IlEA1zVuWslWbiVc23CDn+6ACO5tfPAcc96ED37hxela5ij3VBvAtusw==}
|
||||
engines: {node: '>=20.19'}
|
||||
@@ -1576,6 +1583,9 @@ packages:
|
||||
'@tanstack/store@0.9.3':
|
||||
resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==}
|
||||
|
||||
'@tanstack/virtual-core@3.17.0':
|
||||
resolution: {integrity: sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==}
|
||||
|
||||
'@tanstack/virtual-file-routes@1.162.0':
|
||||
resolution: {integrity: sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA==}
|
||||
engines: {node: '>=20.19'}
|
||||
@@ -1620,7 +1630,6 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tauri-apps/cli-linux-x64-gnu@2.11.2':
|
||||
resolution: {integrity: sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==}
|
||||
@@ -4194,6 +4203,12 @@ snapshots:
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
use-sync-external-store: 1.6.0(react@19.2.6)
|
||||
|
||||
'@tanstack/react-virtual@3.14.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
dependencies:
|
||||
'@tanstack/virtual-core': 3.17.0
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
|
||||
'@tanstack/router-core@1.171.5':
|
||||
dependencies:
|
||||
'@tanstack/history': 1.162.0
|
||||
@@ -4251,6 +4266,8 @@ snapshots:
|
||||
|
||||
'@tanstack/store@0.9.3': {}
|
||||
|
||||
'@tanstack/virtual-core@3.17.0': {}
|
||||
|
||||
'@tanstack/virtual-file-routes@1.162.0': {}
|
||||
|
||||
'@tauri-apps/api@2.11.0': {}
|
||||
|
||||
Reference in New Issue
Block a user