Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions WORK_LOGS/TANSTACK_TIMELINE_SPIKE_CLASSIFIER_2026_07_09.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
---
title: "TanStack Timeline Spike Classifier Result"
tags: [buzz-gui, performance, virtualized-timeline, tanstack, classifier]
status: active
created: 2026-07-09
---

# TanStack Timeline Spike Classifier Result

Lane B worktree: `/Users/tlongwell/.buzz-dev/REPOS/buzz-gui-perf-max-tanstack`
Branch: `max/tanstack-timeline-spike`
Base: `e0f76b0e9cbd1c84f1ed064f120bc38ab7006d46`
Fixture source: `sami/portable-classifier` at `5e5bd4a578ec2bca56f113f036776a4cb1b026b9` copied into the worktree as `desktop/tests/e2e/upscroll-portable-classify.perf.ts` plus `desktop/portable-classifier.pwconfig.ts`.

## Spike shape

The spike uses `@tanstack/react-virtual` for windowing/range math only:

- `TimelineMessageList` flattens timeline items including day dividers into a virtual row array.
- `useVirtualizer` estimates each row via the existing row-height estimator (`estimateTimelineItemHeight`).
- Rows are rendered absolute/transform-positioned inside a total-height spacer.
- `scrollToFn` is intentionally a no-op so TanStack cannot write `scrollTop`/`scrollBy`; the lane thesis was “range math only, no new scroll writer.”

## Validation before classifier

Earlier in this worktree:

- `pnpm --filter desktop typecheck` passed.
- `pnpm --filter desktop build` passed.

Classifier runs:

- Fresh baseline worktree: `/Users/tlongwell/.buzz-dev/REPOS/buzz-gui-perf-max-baseline-e0`
- Baseline build: `pnpm --filter buzz build` passed with existing Vite dynamic-import/chunk warnings.
- Candidate build: `pnpm --filter buzz build` passed with existing Vite dynamic-import/chunk warnings.
- Baseline classifier command: `cd desktop && pnpm exec playwright test -c portable-classifier.pwconfig.ts --project=chromium --project=webkit`
- Candidate classifier command: same command in the TanStack worktree.
- Repeat candidate WebKit command: fresh `pnpm --filter buzz build`, then `cd desktop && pnpm exec playwright test -c portable-classifier.pwconfig.ts --project=webkit`.

## Results

| Engine | Build | Reversals | Still | Max px | Reflow | Tracking | Mid-momentum | Post-momentum bite | Bite max px |
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| Chromium | baseline `e0f76b0e` | 24 | 24 | 60.0 | 0 | 24 | 0 | 24 | 60.0 |
| Chromium | TanStack spike | 21 | 21 | 60.0 | 0 | 21 | 0 | 21 | 60.0 |
| WebKit | baseline `e0f76b0e` | 25 | 25 | 60.0 | 0 | 25 | 0 | 25 | 60.0 |
| WebKit | TanStack spike run 1 | 24 | 21 | 60.0 | 0 | 24 | 3 | 21 | 60.0 |
| WebKit | TanStack spike repeat | 21 | 21 | 60.0 | 0 | 21 | 0 | 21 | 60.0 |

Artifacts on this machine:

- Baseline full output: `/tmp/max-baseline-classifier.log`
- Candidate full output: `/tmp/max-tanstack-classifier.log`
- Candidate WebKit repeat: `/tmp/max-tanstack-webkit-repeat.log`

## Verdict

Not a PR candidate in current form, but the initial mid-momentum kill did **not** reproduce.

Reasoning:

- Eva asked for one WebKit repeat because the first run's mid-momentum count was only `3`. The repeat landed `0`, so the “instant-out” is not stable enough to be the final kill cause.
- Reading the discriminator instead: WebKit post-momentum bite count is stable at `21` vs the same-session baseline `25`, but the bite-set max remains `60.0px`. Chromium also moves only `24 → 21` with bite max still `60.0px`.
- The pre-registered co-gate requires bite count **and** bite max-px both down. The TanStack range-math-only adapter does not pass that gate.
- Eva also flagged a possible fixture bite floor because all lanes keep landing near `21–26` bites with `60.0px` max. If Sami's null-control probe proves a floor, this lane should be read as “does not beat the floor,” not as evidence that TanStack uniquely causes the fixed 60px bite.

## Open caveats / why not continue adapting now

The spike remains intentionally rough and not PR-ready:

- `useAnchoredScroll` is still DOM/querySelector-based and unaware of offscreen unmounted rows.
- Jump-to-message/search/deep-link index semantics are not implemented.
- Prepend anchoring is not virtualizer-owned.
- Day-divider/group semantics are flattened and may differ from the current grouped DOM.
- `useTanStackTimeline = true` is hardcoded and must not ship.

Because the classifier did not pass the co-gated discriminator, I did not spend more time polishing these seams for a judged PR candidate. If the team later wants a deeper TanStack lane, the next variant should defer or freeze measurement/transform reconciliation during active scroll and implement index-model seams before judged acceptance.
32 changes: 32 additions & 0 deletions desktop/portable-classifier.pwconfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
testDir: "./tests/e2e",
testMatch: "**/upscroll-portable-classify.perf.ts",
timeout: 30_000,
retries: 0,
workers: 1,
reporter: [["list"]],
use: {
baseURL: "http://127.0.0.1:4173",
screenshot: "only-on-failure",
trace: "off",
video: "off",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"], browserName: "chromium" },
},
{
name: "webkit",
use: { ...devices["Desktop Safari"], browserName: "webkit" },
},
],
webServer: {
command: "python3 -m http.server 4173 -d dist",
cwd: ".",
reuseExistingServer: false,
url: "http://127.0.0.1:4173",
},
});
19 changes: 11 additions & 8 deletions desktop/src/features/messages/lib/rowHeightEstimate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,16 +164,19 @@ const DIVIDER_HEIGHT = 32;
* near its true height instead of snapping the scroll position. `auto` keeps
* refining once the row paints.
*/
export function estimateTimelineItemHeight(item: TimelineItem): number {
return item.kind === "message"
? estimateRowHeight(item.entry.message, {
isContinuation: item.isContinuation,
}) + (item.isFollowedByContinuation ? 0 : MESSAGE_ITEM_BOTTOM_PADDING)
: item.kind === "system"
? estimateRowHeight(item.entry.message)
: DIVIDER_HEIGHT;
}

export function timelineRowReserveStyle(
item: TimelineItem,
): React.CSSProperties {
const height =
item.kind === "message"
? estimateRowHeight(item.entry.message, {
isContinuation: item.isContinuation,
}) + (item.isFollowedByContinuation ? 0 : MESSAGE_ITEM_BOTTOM_PADDING)
: item.kind === "system"
? estimateRowHeight(item.entry.message)
: DIVIDER_HEIGHT;
const height = estimateTimelineItemHeight(item);
return { containIntrinsicSize: `auto ${height}px` };
}
42 changes: 38 additions & 4 deletions desktop/src/features/messages/ui/MessageTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,18 @@ import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
import type { ChannelWindowThreadSummary } from "@/features/messages/lib/channelWindowStore";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { ChannelType } from "@/shared/api/types";
import { useFeatureEnabled } from "@/shared/features";
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 { TimelineSkeleton, useTimelineSkeletonRows } from "./TimelineSkeleton";
import { TimelineMessageList } from "./TimelineMessageList";
import {
TimelineMessageList,
type TimelineTanStackVirtualizerHandle,
} from "./TimelineMessageList";
import { useAnchoredScroll } from "./useAnchoredScroll";
import { useLoadOlderOnScroll } from "./useLoadOlderOnScroll";

Expand All @@ -39,7 +43,8 @@ type MessageTimelineProps = {
messages: TimelineMessage[];
mainEntries?: MainTimelineEntry[];
/** Relay thread summaries (root id → summary) for the deferred-pass entry
* fallback, so badge rows survive while a scrollback page commits. */
* fallback, so relay-backed badges are reconstructed correctly if their
* summary rows unmount/remount during scrollback virtualization. */
threadSummaries?: ReadonlyMap<string, ChannelWindowThreadSummary>;
directMessageIntro?: {
displayName: string;
Expand Down Expand Up @@ -236,6 +241,13 @@ const MessageTimelineBase = React.forwardRef<
liveCount: messages.length,
});
const showTimelineSkeleton = timelineBodySurface === "skeleton";
const useTanStackTimeline = useFeatureEnabled("tanstackTimeline");
const [tanStackRenderVersion, bumpTanStackRenderVersion] = React.useReducer(
(version: number) => version + 1,
0,
);
const tanStackVirtualizerRef =
React.useRef<TimelineTanStackVirtualizerHandle | null>(null);

const {
highlightedMessageId,
Expand All @@ -253,6 +265,20 @@ const MessageTimelineBase = React.forwardRef<
onTargetReached,
scrollContainerRef,
targetMessageId,
virtualScrollTargetForMessage: useTanStackTimeline
? (messageId: string) =>
tanStackVirtualizerRef.current?.getScrollTargetForMessage(
messageId,
) ?? null
: undefined,
virtualStartOffsetForMessage: useTanStackTimeline
? (messageId: string) =>
tanStackVirtualizerRef.current?.getStartOffsetForMessage(messageId) ??
null
: undefined,
virtualizerRenderVersion: useTanStackTimeline
? tanStackRenderVersion
: undefined,
});

const timelineIntroSurface = selectTimelineIntroSurface({
Expand Down Expand Up @@ -282,8 +308,10 @@ const MessageTimelineBase = React.forwardRef<
[scrollToBottomOnNextUpdate],
);

// Jump-to-message is purely DOM-based now: all loaded rows are mounted, so
// `scrollToMessage` always finds the target row. No virtualizer convergence.
// Jump-to-message first tries the existing DOM path; with the TanStack list,
// offscreen rows are intentionally unmounted, so fall back to TanStack's
// measured/estimated index model and still keep the scroll write centralized
// in the timeline anchoring adapter.
const jumpToMessage = React.useCallback(
(messageId: string, options?: { behavior?: ScrollBehavior }) => {
return scrollToMessage(messageId, { highlight: true, ...options });
Expand Down Expand Up @@ -612,6 +640,12 @@ const MessageTimelineBase = React.forwardRef<
searchActiveMessageId={searchActiveMessageId}
searchMatchingMessageIds={searchMatchingMessageIds}
searchQuery={searchQuery}
scrollContainerRef={scrollContainerRef}
useTanStackVirtualization={useTanStackTimeline}
onTanStackVirtualizer={(virtualizer) => {
tanStackVirtualizerRef.current = virtualizer;
}}
onTanStackRangeChanged={bumpTanStackRenderVersion}
threadUnreadCounts={threadUnreadCounts}
unfollowThreadById={unfollowThreadById}
/>
Expand Down
Loading
Loading