Skip to content

Timeline: scrolling up a thread with very long replies can loop the list into React's maximum update depth #228

Closed

Description

@mubeda

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:
    function getItemType(item: MessagesTimelineRow) {
      return item.kind === "message" ? `message:${item.message.role}` : item.kind;
    }
    So every assistant message shares one item type, message:assistant, whatever its length.
  • MessagesTimeline.tsx:680-700 passes it to LegendList with getItemType={getItemType}, estimatedItemSize={90}, recycleItems={false}, maintainScrollAtEnd (via resolveTimelineMaintainScrollAtEnd) and maintainVisibleContentPosition={{ 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/list is 3.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.text is always a string (packages/contracts/src/orchestration.ts:361, ChatMessage in apps/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)

  1. 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:assistant average rises past 9,000 px.
  2. 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.
  3. 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.
  4. 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.
  5. 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.ts and timelineScrollAnchoring.test.tsx all 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)

  1. 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.
  2. 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

  1. Bucket assistant and user rows by text length, as T3 did:
    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}`;
    }
    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.
  2. Move the function into MessagesTimeline.logic.ts (exported, e.g. resolveTimelineItemType) so it can be unit tested without the list.
  3. 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.
  4. 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.ts for 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 user and assistant;
    • every non-message kind (queued-message, work, work-toggle, turn-fold, proposed-plan, working) keeps its current type.
  • MessagesTimeline.tsx uses the shared function. MessagesTimeline.test.tsx and timelineScrollAnchoring.test.tsx still pass (from apps/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 getItemType reverted, the spec fails or shows the error. Say in the PR if it doesn't, and give the nested-update count it measured.
  • 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 test for 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 --workspace and vp run check:contracts too.
  • UI.md and vercel-react-best-practices reviews are recorded. docs/testing/ runbooks are reviewed and updated (the new spec in cross-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

Related

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

  1. added
    bugSomething is broken or behaving incorrectly.
    on Oct 31, 2026
  2. mubeda commented on Oct 31, 2026

    @mubeda
    OwnerAuthor

    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.

    getItemType feeds the same LegendList that #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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething is broken or behaving incorrectly.

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions