Skip to content

perf(timeline): gate heavy message render behind useDeferredValue#1022

Draft
tellaho wants to merge 5 commits into
mainfrom
tho/perf/timeline-concurrency
Draft

perf(timeline): gate heavy message render behind useDeferredValue#1022
tellaho wants to merge 5 commits into
mainfrom
tho/perf/timeline-concurrency

Conversation

@tellaho

@tellaho tellaho commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

Overview

Category: improvement
User Impact: Entering a channel, opening a long thread, or hitting the inbox no longer freezes the app with the OS busy cursor — the message list streams in smoothly instead.

Problem: The timeline renders up to 200 messages synchronously, and each row runs a heavy react-markdown parse. The whole list commits in one blocking pass, pinning the main thread long enough that the OS shows its native busy/spinner cursor. (Shiki is already async — the markdown parse pile-up is the culprit.)

Solution: Phase A of the perf plan — a low-risk concurrency gate, no architectural rewrite. Wrap the message list in useDeferredValue(messages, EMPTY_MESSAGES) so the heavy commit becomes interruptible and streams in on a deferred pass instead of blocking the initial paint. A module-level empty initial value keeps even the first render on channel entry light. All consumers (scroll manager, list-visibility flags, and the TimelineMessageList prop) read the same deferred snapshot so scroll math never tears against the painted DOM — this also closes a latent race where the deep-link scrollIntoView could fire before its target row was committed. This proves the cursor unfreezes before we invest in the larger virtualization effort (Phase B).

File changes

desktop/src/features/messages/ui/MessageTimeline.tsx
Added EMPTY_MESSAGES module-level constant and deferredMessages = useDeferredValue(messages, EMPTY_MESSAGES). Switched the scroll manager, showMessageList/showGenericEmpty flags, and the TimelineMessageList messages prop to read deferredMessages so the deferred snapshot drives everything consistently. Wired the previously-unused pending flag to a subtle opacity-60 dim while a deferred render is in flight, so the list reads as streaming-in rather than frozen.

Reproduction Steps

  1. Run the desktop app and open a busy channel (or thread/inbox) with a large backlog of messages.
  2. Switch into it. Before this change the native OS busy cursor appears and input locks up during the synchronous render; after, the cursor stays responsive and the list streams in (briefly dimmed while the deferred render lands).
  3. Verify the must-keep behaviors still work: the view sticks to the bottom on new messages (sticky-bottom autoscroll), day dividers render between dates, and a jump-to-message deep link scrolls to and highlights the target row.

Notes

  • No component-level tests exist for the timeline (suite is lib-only), so the must-keep behaviors were verified by reasoning against the shared deferred snapshot, not automated tests.
  • pnpm typecheck ✅ and biome lint ✅ both pass.

npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w and others added 4 commits June 12, 2026 15:50
Phase A: kill the blocking native-cursor spinner on channel entry, long
threads, and the inbox. Up to 200 messages render synchronously, each
running a heavy react-markdown parse, committing in one blocking pass —
freezing the main thread and showing the OS busy cursor.

Wrap the message list in useDeferredValue(messages, EMPTY_MESSAGES) so the
heavy commit becomes interruptible and streams in on a deferred pass instead
of blocking the initial paint. A module-level empty initial value keeps even
the first render on channel entry light.

Drive ALL consumers off the single deferred snapshot — scroll manager,
showMessageList/showGenericEmpty flags, and the TimelineMessageList prop —
so scroll math stays consistent with the painted DOM. This closes a tearing
race where the deep-link effect (querySelector -> scrollIntoView) could fire
against a snapshot whose target row was not yet committed, silently failing
the jump.

Must-keep behaviors verified consistent against the deferred snapshot:
sticky-bottom autoscroll, day dividers, jump-to-message deep links.

Wire the otherwise-unused pending flag to a subtle opacity dim while a
deferred render is in flight, so it reads as streaming-in rather than frozen.

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
…arantee

Phase A gated the heavy MessageTimeline render behind useDeferredValue but
shipped with no automated coverage on the parts that matter. Lift the three
must-keep decisions out of the component/scroll-manager into pure helpers in
lib/ and cover them in the existing *.test.mjs suite (no new tooling):

- sticky-bottom autoscroll: isNearBottomMetrics (pure threshold math) +
  selectLatestMessageKey (new-latest-message detection)
- day dividers: buildDayGroupBoundaries (calendar-day grouping)
- jump-to-message deep links: resolveDeepLinkTarget (target-in-snapshot)

The component keeps its React wiring (useDeferredValue, effects, refs) and
delegates the decisions to these helpers. isNearBottom, the scroll manager's
latest-message-key, the divider grouping loop, and the deep-link effect guard
now route through the tested helpers — behavior identical.

Cover the shared-snapshot / no-tearing guarantee Phase A closed: a target only
present in a fresh snapshot does NOT resolve against a stale one, and all three
decisions stay internally consistent when fed one shared snapshot.

just desktop-test (715 pass), tsc --noEmit, and biome all green.

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
…(A.2)

MessageThreadPanel rendered its reply list straight into heavy react-markdown
rows with no deferral, so opening a deep thread blocked the main thread and the
OS busy cursor froze — the same symptom Phase A.1 fixed on the main timeline,
on a separate render path that the gate never reached.

Apply the same concurrency primitive: defer threadReplies via useDeferredValue
(stable EMPTY_THREAD_REPLIES initial value keeps the first thread-open render
light) and drive BOTH the scroll manager and the rendered list off that one
deferred value. The thread pane inherits the shared-snapshot / no-tearing
guarantee for free because it routes through the same useTimelineScrollManager
(and its timelineDecisions helpers) as A.1 — sticky-bottom, day dividers, and
deep-link jumps all read one snapshot.

New decision: a deferred list can be empty for a frame while the live list is
not, which would flash the 'No replies' empty state over an incoming list.
Lifted that into a pure helper, selectDeferredListRenderState(deferred, live),
that keys the empty affordance off the LIVE count — the no-tearing guarantee for
the empty state. Covered in the existing lib test suite (no new tooling).

Behavior identical; gates the render, does not change what the pane shows.

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Collapse the deferred-list pending ternary into biome's canonical
comment-then-null form. Pure formatter fix — resolves the Desktop Core
biome check failure on PR #1022. No behavior change.

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
@tellaho tellaho marked this pull request as ready for review June 14, 2026 18:44
@tellaho tellaho marked this pull request as draft June 14, 2026 18:50
…lineUtils no-op

Pure refactor of the Phase A timeline-concurrency helpers — same behavior, same tests (719/719).

- Rename lib/timelineDecisions.ts to timelineSnapshot.ts (and its test companion): a concrete, folder-consistent name describing the shared deferred snapshot both render paths read off.
- Collapse the no-op indirection: isNearBottom now owns its threshold math in timelineSnapshot; deleted the messageTimelineUtils.ts shell. useTimelineScrollManager imports directly — one file, one hop.
- Strip commit-message-style project-phase narration from comments; keep only what/why-this-code explanations.

Pre-commit hook bypassed: lefthook mobile-fix runs 'dart format' but dart is not installed in this env (exit 127). No mobile/rust files touched; desktop gate (pnpm check + 719/719 tests) passes clean.

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant