Repository navigation
Description
Summary
Scrolling up through a very long chat can crash the Timeline with React's "Maximum update depth exceeded" (error #185). This happens when the thread has a few very long assistant replies above many short ones. T3 Code hit this exact bug in the same list library (@legendapp/list) and fixed it in pingdotgg/t3code#21269. BiBCode's timeline has the same item-type function they fixed, so it is open to the same failure.
We have not reproduced it in BiBCode yet. It is filed as a bug because the code path is the same as theirs and their failure mode follows from it (details below).
Current behavior (verified at a0ea849ef92b)
Item types group every assistant reply together
apps/web/src/components/chat/MessagesTimeline.tsx:852-854:So every assistant message shares one item type,function getItemType(item: MessagesTimelineRow) { return item.kind === "message" ? `message:${item.message.role}` : item.kind; }
message:assistant, whatever its length.MessagesTimeline.tsx:680-700passes it toLegendListwithgetItemType={getItemType},estimatedItemSize={90},recycleItems={false},maintainScrollAtEnd(viaresolveTimelineMaintainScrollAtEnd) andmaintainVisibleContentPosition={{ data: true, size: true }}.- Per T3's analysis, LegendList estimates an unmeasured row from the average measured size of rows of the same item type. One type for all assistant replies means a few huge replies set the estimate for every short one.
- Row kinds come from
MessagesTimeline.logic.ts:274-332:queued-message,work,work-toggle,turn-fold,message,proposed-plan,working. @legendapp/listis3.3.10(apps/web/package.json:27). T3 was on 3.3.5 with a patch. I didn't check whether 3.3.6–3.3.10 changed how unmeasured rows are estimated.message.textis always astring(packages/contracts/src/orchestration.ts:361,ChatMessageinapps/web/src/types.ts:45), so a length bucket needs no null handling. Empty text (a streaming reply's first frame) must still map to bucket 0.
How it fails (T3's analysis in #21269, matching our code path)
- A thread has a few assistant replies that render around 40,000 px tall (long code blocks or logs). Once those are measured, the
message:assistantaverage rises past 9,000 px. - Short replies further up have not been measured yet, so they are placed at that average. In T3's trace, rows were placed around 17,000–22,000 px apart.
- As the user scrolls up, each short reply mounts and measures at 100–700 px. The list total shrinks by about 22,000 px, which pulls the next unmeasured row into the viewport in the same layout-effect chain.
- That row then measures, shrinks the total, and pulls in the next one. Each step is one nested synchronous update (
measureContainersInLayoutEffect → updateItemSizesBatch → applyItemSize → updateOneItemSize → setSize → addTotalSize → set$ → forceStoreRerender). Past React's limit of 50 nested updates, React throws error Create worktree dialog ignores the Default Agent set in Settings #185 and the view crashes. - T3's repro had 2,711 messages and 8,064 turn items, with 20–26 nested updates per scroll step even when it didn't crash.
Existing tests
apps/web/src/components/chat/MessagesTimeline.test.tsx,MessagesTimeline.logic.test.tsandtimelineScrollAnchoring.test.tsxall run in happy-dom with the list mocked or unmeasured (docs/testing/cross-platform-validation.md:742: "Component tests replace the virtualized list with a mock, so they cannot show scrolling"). None of them can show this bug.- The real list is exercised only by packaged e2e specs such as
apps/desktop/e2e/specs/chat-reasoning-rows.e2e.ts. No spec builds a thread with long replies above many short ones.
Not measured. No BiBCode repro, nested-update count or thread size where it starts. #103 (closed) reported main-thread saturation while scrolling up a long timeline and suspected underestimated heights from estimatedItemSize={90}. That is related, but this is the opposite problem: heights overestimated from a type average.
Steps to reproduce (expected, not yet run on BiBCode)
- Open a thread with a few assistant replies tall enough to render around 40,000 px, followed by many (80+) short replies. Put the long ones last, so they're measured first when the thread opens at the end.
- Scroll quickly to the top of the timeline.
Expected: the timeline scrolls normally, and rows may shift a little as they're measured.
Actual (T3, same code path): "Maximum update depth exceeded", and the view crashes.
Proposed change
- Bucket assistant and user rows by text length, as T3 did:
Replies up to 2,000 characters share bucket 0 and longer ones double per bucket. A short reply then gets its estimate from other short replies, never from a 40,000 px one. Non-message kinds keep their current type.
function getItemType(item: MessagesTimelineRow) { if (item.kind !== "message") return item.kind; const lengthBucket = Math.max(0, Math.floor(Math.log2(item.message.text.length / 1000))); return `message:${item.message.role}:${lengthBucket}`; }
- Move the function into
MessagesTimeline.logic.ts(exported, e.g.resolveTimelineItemType) so it can be unit tested without the list. - A streaming reply changes bucket as it grows (at 2,000, 4,000, 8,000 … characters). The PR must confirm that a type change while streaming doesn't remount the row or cause a visible jump at the live end (Timeline: the packaged resume-at-end check sometimes finds the list at the top after a turn settles #215, Timeline: a finished streamed reply drops ~90 px for a frame when its work-log row turns into "Worked for" #208), with
recycleItems={false}as today. - Leave
estimatedItemSize={90}alone unless the e2e measurement below shows a reason to change it.
T3 Code reference (their claims, not measured by us)
- pingdotgg/t3code#21269, "scrolling a huge thread's history no longer loops the message list" (commit
1114c3815b, merged 2026-10-18 17:04 ART). They report:- React error Create worktree dialog ignores the Default Agent set in Settings #185 while scrolling a huge running thread, with the stack above;
- a repro thread of 2,711 messages and 8,064 turn items, with 20–26 nested updates per step;
- a probe going from 20/5/20 nested updates to 2/2/2 after the fix;
- a browser test that prepends 80 short replies above 4 long ones, scrolls to the top and asserts the nested chain stays below 8.
Acceptance criteria
- Unit table test in
apps/web/src/components/chat/MessagesTimeline.logic.test.tsfor the exported type function:- text lengths 0, 1, 999, 1,000, 1,999, 2,000, 3,999, 4,000 and 100,000 map to the expected buckets, for both
userandassistant; - every non-message kind (
queued-message,work,work-toggle,turn-fold,proposed-plan,working) keeps its current type.
- text lengths 0, 1, 999, 1,000, 1,999, 2,000, 3,999, 4,000 and 100,000 map to the expected buckets, for both
-
MessagesTimeline.tsxuses the shared function.MessagesTimeline.test.tsxandtimelineScrollAnchoring.test.tsxstill pass (fromapps/web:vp test run --project unit src/components/chat/MessagesTimeline.logic.test.ts src/components/chat/MessagesTimeline.test.tsx src/components/chat/timelineScrollAnchoring.test.tsx). - Regression e2e on the real list, new
apps/desktop/e2e/specs/chat-huge-thread-scroll.e2e.ts:- add a deterministic fixture marker in
apps/desktop/e2e/support/test-project.ts(next to[[reasoning]]), for example[[long-reply]], that answers with text long enough to render well over 10,000 px; - build a thread with 80+ short turns, then 4
[[long-reply]]turns at the end; - reopen it at the end, then scroll to the top in steps (PageUp and a direct
scrollTop = 0); - assert the timeline is still mounted, the first message is visible, and the app log has no "Maximum update depth exceeded" (
apps/desktop/e2e/support/app-log-evidence.ts); - with only
getItemTypereverted, the spec fails or shows the error. Say in the PR if it doesn't, and give the nested-update count it measured.
- add a deterministic fixture marker in
- Existing packaged specs still pass:
chat-reasoning-rows.e2e.ts,mermaid-diagrams.e2e.ts,document-navigation.e2e.ts,composer-message-queue.e2e.ts(vp run test:ui:desktop). - Existing behavior unchanged: opening at the end, following the live end while a reply streams, and keeping the reading line in place when a row above changes size.
- Gates:
vp check(zero warnings),vp run typecheck,vp testfor the touched web files, Release Smoke. No Rust or contract changes are expected; if any appear,cargo fmt --all --check,cargo clippy --workspace --all-targets -- -D warnings,cargo test --workspaceandvp run check:contractstoo. - UI.md and
vercel-react-best-practicesreviews are recorded.docs/testing/runbooks are reviewed and updated (the new spec incross-platform-validation.md) or stated "remain accurate". - Profiler coverage line in the delivery report. Message text never reaches profiler records or logs; the e2e asserts on the error string only.
Living docs to update
docs/testing/cross-platform-validation.md(near:742): the new huge-thread scroll spec and what it proves.docs/architecture/rpc-and-orchestration.md(:1393-1408, where LegendList is described): one line on item types being length-bucketed.
Not in scope
- Changing
estimatedItemSize,recycleItemsor the scroll anchoring logic. - Upgrading
@legendapp/listor patching it. - The thread history window (Thread view: open threads with a byte-budgeted history window and page older history on scroll #226), which limits how much history is loaded but would not by itself prevent this inside a loaded window.
Related
- client.interaction: scrolling up a long chat timeline saturates the main thread #103 (closed): scrolling up a long timeline saturated the main thread; suspected underestimated heights.
- Timeline: a finished streamed reply drops ~90 px for a frame when its work-log row turns into "Worked for" #208, Timeline: a scroll key during the sent-row glide can jump the reading line about 144 px #214, Timeline: the packaged resume-at-end check sometimes finds the list at the top after a turn settles #215, macOS x64: PageUp during the sent-row glide sometimes doesn't page up #207: glide and live-end anchoring issues in the Timeline. They don't look caused by this bug, but Timeline: a finished streamed reply drops ~90 px for a frame when its work-log row turns into "Worked for" #208 ("drops ~90 px for a frame") involves the same 90 px estimate, and bucketing changes when a streaming reply's type changes. Check them when testing.
- Thread view: open threads with a byte-budgeted history window and page older history on scroll #226: byte-budgeted thread history window.
Open questions
- Is T3's 1,000-character log2 bucket right for BiBCode's rows, which also render attachments, file chips and Mermaid diagrams? Should rows with attachments or diagrams get their own bucket?
- Should the e2e fail on the nested-update count (needs a dev-only probe) or only on the error string?
Activity
mubeda commentedon Oct 31, 2026
Triage, 2026-10-31. Not going into PR #213 (#139 MCP batch). It goes in a follow-up PR after that release. Batch 2 (quick fixes), the first timeline follow-up.
getItemTypefeeds the sameLegendListthat #207, #208 and #214 were just stabilized against in PR #213, and #215 is open on the same list. Bucketing by length changes a streaming reply's item type as it grows. Acceptance should therefore include re-running the packaged timeline e2e specs for those issues (sent-row glide, the "Worked for" handoff, resume at the end), plus a BiBCode repro or a nested-update count before the fix.Planned order after the release: (1) #222 copy lint; (2) quick fixes #228, #221, #229, #231, #232; (3) MCP #220; (4) performance #225, then #223, #226, #227; (5) features #230, #224, #211, #212; (6) #210 after a Windows repro.
Merge remote-tracking branch 'origin/v015/228-215-timeline' into v015…