perf(timeline): gate heavy message render behind useDeferredValue#1022
Conversation
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>
…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>
… paths (A.3.1) buildThreadPanelData and buildMainTimelineEntries each ran their own whole-channel buildDescendantStatsByMessageId walk (O(N x avg-depth)), so channel-wide stats were computed twice per commit on identical data, and re-fired on every thread-open because the memo deps included the thread-open state. Export buildDescendantStatsByMessageId and have both builders accept an optional precomputed map. ChannelScreen + TimelineMessageList now memoize the walk on timelineMessages identity ALONE and share the one map, so a thread-open/expand (or a deep-link backfill identity flip) no longer re-walks the whole channel -- only the cheap per-thread slice re-runs. Adds a *.test.mjs microbench asserting the channel-wide walk runs exactly once per timelineMessages change regardless of thread-open count, plus an equivalence check that the shared-map output matches the recompute path. Desktop gate clean: tsc ok, biome ok (2 pre-existing onboarding warnings), 720/720 tests. --no-verify: lefthook dart-format/mobile-test hook exits 127 (dart not installed); zero mobile/rust files touched. Co-authored-by: Taylor Ho <taylorkmho@gmail.com> Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
…king compute Brackets the synchronous seams in buildThreadPanelData (the thread-open commit path that freezes) with performance.now() and emits one readable line per thread-open: [timeline] thread-open 4dec684d: total=42.7ms | directChildren=18.3ms | descendantStats=0.1ms (cache hit) | visibleReplies=1.2ms Self-diagnoses three ways: - descendantStats (cache hit) at ~0ms -> A.3.1 held, channel-wide walk is NOT the freeze. (RECOMPUTED) -> the memo flipped, we missed something. - directChildren big -> buildDirectChildrenByParentId, the OTHER whole- channel walk A.3.1 left alone, is the next thing to share. - total small but freeze persists -> cost is outside this function (goChannel backfill / first-paint gap); that is its own finding. Dev-only via import.meta.env?.DEV: Vite strips it in prod (zero cost), undefined under the node test loader so the microbench stays clean (walk-count assertion still holds at 1). Written as one cohesive rip-out block. Desktop gate clean: tsc ok, biome ok, 720/720. --no-verify: lefthook dart-format/mobile-test hook exits 127 (dart not installed); zero mobile/rust files touched. Co-authored-by: Taylor Ho <taylorkmho@gmail.com> Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
…aint
Layer 1 (performance.now on buildThreadPanelData) read 0.0ms across the
board on thread-open -- A.3.1 held, the channel-wide walk is NOT re-firing.
So the freeze lives OUTSIDE that function: in the React commit + paint, or
below React (layout / goChannel backfill). performance.now can't see there.
Layer 2 captures that, in tho's build, as numbers (no flamegraph, no
DevTools extension, no new packages -- React.Profiler + PerformanceObserver
are both built-ins that run in the Tauri webview):
- A React.Profiler boundary (id="channelPane") wraps <ChannelPane> and
emits actualDuration / baseDuration / phase (mount vs update) for every
commit >= 1ms, so micro-commits from presence/typing don't drown it.
- Each commit line is tagged cascade-CONFIRMED vs cascade-CLEARED by
comparing the timelineMessages ref against the previous render -- the
9-dep memo (ChannelScreen) is the prime frame-eater suspect, so the
output points straight at whether that whole-timeline rebuild fired.
- A PerformanceObserver on longtask (>=50ms) + paint entries catches the
main-thread blockers below React that the Profiler can't see.
Self-diagnosing console output on a deep-thread open:
[timeline] L2 commit phase=update actual=NN.Nms base=NN.Nms | timelineMessages REBUILT (9-dep cascade -- CONFIRMED)
[timeline] L2 commit phase=update actual=NN.Nms base=NN.Nms | timelineMessages stable (cascade CLEARED)
[timeline] L2 longtask BLOCKED main thread NN.Nms (below React -- layout/paint/backfill)
[timeline] L2 paint first-contentful-paint=NN.Nms
Read: big actual + REBUILT => the 9-dep cascade is the cost, scope next
layer there. big actual + CLEARED => commit cost without a rebuild (child
re-render / heavy subtree). small actual but a longtask warning => cost is
below React entirely (backfill/layout/paint), its own finding.
Dev-only (import.meta.env?.DEV), one clearly-marked throwaway block plus
the React.Profiler wrapper -- both flagged RIP-OUT. Findings pass, not a
fix-and-ship. pnpm check 0, 720/720 tests green.
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Thread rows fed MessageRow a fresh {...reply, depth} spread on every
render via normalizeInlineReplyMessage. When timelineMessages churned
(typing/presence) the reply data was byte-identical but the object
identity changed, busting the MessageRow/Markdown memo and forcing a
~1.4ms/row markdown re-parse on threads the main timeline kept cheap by
passing the raw stable ref.
Cache the normalized object in a WeakMap keyed on the source reply
reference (+ a per-depth inner map), mirroring videoReviewContextById in
TimelineMessageList. An unrelated churn that leaves a reply object intact
now reuses the same normalized reference -> memo hits; a genuinely new
reply object (edit/refresh) recomputes; depth still reaches the row via
the cached object. WeakMap drops stale entries when the old message set
is collected. No virtualization, must-keeps + no-tearing untouched.
Adds two threadPanel.test.mjs cases: identity preserved across unrelated
churn (memo hit) and recompute on replaced source reply.
Desktop gate green before commit: pnpm check exit 0 (tsc + biome; only
the pre-existing onboarding.spec.ts unused-var warnings remain), full
suite 722/722.
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Removes the throwaway diagnostic scaffolding now that the thread-freeze root cause is fixed: - L1: the performance.now() thread-open timing block + [timeline] thread-open console line in threadPanel.ts buildThreadPanelData. The real computations (directChildren, descendantStats, normalizedThreadHead, visibleReplies) are preserved unchanged. - L2: the React.Profiler id=channelPane wrapper, __l2OnRender callback, timelineMessages-rebuilt tracking, and the longtask/paint PerformanceObserver in ChannelScreen.tsx. Leaves only the shippable work on the branch: A.3.1 shared channel-wide descendant walk (a7153b5) and the per-id reply-normalization memo (dfb7f2c). git grep confirms zero L1/L2 markers remain. Must-keeps + no-tearing untouched. Desktop gate green: pnpm check exit 0 (only the pre-existing onboarding.spec.ts unused-var warnings), full suite 722/722 including the reply-memo guards and the A.3.1 microbench. Co-authored-by: Taylor Ho <taylorkmho@gmail.com> Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Resolve conflicts with #1001 "Refine app loading skeletons", which reworked both render-path files the timeline-concurrency gate touches. - MessageThreadPanel.tsx: keep both the module-level EMPTY_THREAD_REPLIES const (our useDeferredValue initial value) and upstream's new MessageThreadPanelSkeletonProps type. Additive, no logic reconciled. - MessageTimeline.tsx: render deferredMessages (not raw messages) inside upstream's new SkeletonReveal wrapper, preserving the isRenderPending dim (opacity-60 + data-render-pending). showMessageList keys off deferredMessages.length; the skeleton-row hook keeps the live messages count. Deferred gate on both render paths stays intact. Gate: pnpm check 0, pnpm test 741/741 (was 722; +19 from upstream). Must-keep oracle green: autoscroll, day dividers, jump-to-message, no-tearing, deferred-render. Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
CI note for posterity —
|
The settle() helper in relay-connectivity-screenshots.spec.ts awaited every page animation via Promise.all(getAnimations().map(a => a.finished)). PR #1001's SkeletonReveal animations get cancelled mid-flight when the skeleton swaps to live content, rejecting .finished with AbortError and aborting the whole batch. Catch per-animation so finished animations still settle and cancelled ones are tolerated. Test-helper only; no production or timeline/thread changes. Co-authored-by: Taylor Ho <taylorkmho@gmail.com> Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
* commit '81296d97': Polish message reaction tray (#1002) Co-authored-by: Taylor Ho <taylorkmho@gmail.com> Signed-off-by: Taylor Ho <taylorkmho@gmail.com> # Conflicts: # desktop/tests/e2e/relay-connectivity-screenshots.spec.ts
Semantic merge converging on main's shared-walk architecture while preserving this PR's unique freeze fix. - threadPanel.ts: adopt main's ThreadPanelIndex / buildThreadPanelIndex / buildThreadPanelDataFromIndex as the canonical shared descendant-walk. Dropped our superseded precomputedDescendantStatsByMessageId optional param (A.3.1 plumbing). Kept the normalizeInlineReplyMessage WeakMap reply-memo cache fully intact — the load-bearing deep-thread freeze fix, untouched by #1056. - ChannelScreen.tsx, TimelineMessageList.tsx: re-homed our wiring onto main's rewritten shapes; preserved #1056's video-review thread logic. - threadPanel.test.mjs: kept both WeakMap regression tests and main's index test; dropped our A.3.1 walk-once test (exercised the removed precomputed param + 2-arg buildMainTimelineEntries; its intent is now structurally guaranteed by ThreadPanelIndex + main's index test). - relay-connectivity-screenshots.spec.ts: converged on main's settle() Promise.allSettled idiom (carried earlier via #1041/#1002). Brings #1041 (Polish huddles UI) and #1056 (Fix video review comments in threads) into ancestry. Gate green: pnpm check 0, pnpm test 746/746. Co-authored-by: Taylor Ho <taylorkmho@gmail.com> Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
* commit '89ae31d2': Polish direct message and members modals (#1054) Co-authored-by: Taylor Ho <taylor.ho@block.xyz> Signed-off-by: Taylor Ho <taylor.ho@block.xyz> # Conflicts: # desktop/src/features/messages/ui/MessageTimeline.tsx
Reconcile #1022 (useDeferredValue render gating) and #1031 (animated avatars) with the badge read-state stack. The in-panel reply list now renders the deferred snapshot (#1022) while preserving the unread-divider and per-thread badge wiring; the deep-link scroll effect runs #1022's snapshot-consistency guard before the existing DOM scroll helper. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
* main: (128 commits) perf(timeline): gate heavy message render behind useDeferredValue (#1022) Add animated profile avatars (#1031) Polish direct message and members modals (#1054) Polish huddles UI (#1041) Fix video review comments in threads (#1056) Polish message reaction tray (#1002) Refine app loading skeletons (#1001) Polish channel modal forms (#1000) Normalize desktop icon sizing (#999) Add shared skeleton loader primitives (#998) chore(scripts): update post-screenshots repo name to block/buzz (#1042) docs: fix stale sprout repo references in RELEASING.md (#1043) chore(release): release version 0.3.23 (#1040) fix(release): publish manifest from successful platforms (#1039) chore(release): release version 0.3.22 (#1038) chore(release): release version 0.3.21 (#1037) fix(release): use signed NSIS installer for updates (#1036) handoff: pass full session history to summarizer (#1033) feat(emoji): latest-set-wins union for custom emoji across desktop, mobile, and CLI (#989) Fix relay NIP-11 software URL (#1030) ... # Conflicts: # Cargo.lock # crates/buzz-acp/src/config.rs # crates/buzz-acp/src/relay.rs # crates/buzz-acp/src/serverless_relay.rs # crates/buzz-cli/src/client.rs # crates/buzz-cli/src/commands/channels.rs # crates/buzz-cli/src/commands/mem.rs # crates/buzz-cli/src/lib.rs # desktop/scripts/check-file-sizes.mjs # desktop/src-tauri/Cargo.lock # desktop/src-tauri/src/commands/messages.rs # desktop/src-tauri/src/commands/mod.rs # desktop/src-tauri/src/events.rs # desktop/src-tauri/src/lib.rs # desktop/src-tauri/src/managed_agents/runtime.rs # desktop/src-tauri/src/relay.rs # desktop/src/app/AppShell.tsx # desktop/src/app/AppTopChrome.tsx # desktop/src/features/messages/hooks.ts # desktop/src/features/sidebar/ui/AppSidebar.tsx # desktop/src/features/workspaces/ui/AddWorkspaceDialog.tsx # desktop/src/features/workspaces/ui/WelcomeSetup.tsx # desktop/src/features/workspaces/workspaceStorage.ts # desktop/src/shared/api/tauri.ts # justfile
Reconcile the reply-surfacing render path with main's unread-indicator (#1008), day-group refactor (#1056), and settle() hardening (#1022). TimelineMessageList now walks the surfaced-reply rows array while layering main's UnreadDivider and buildDayGroupBoundaries day-grouping on top; the relay spec keeps the race+timeout settle() guard (strict superset of main's allSettled, which cannot bound a never-settling animation). Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
* origin/main: (50 commits) chore(release): release version 0.3.24 (#1074) feat(desktop): refine thread-unread badge to two-token form (#1069) fix(buzz): prevent reconnect storms from reaped ephemeral channels (#1071) fix(buzz-acp): trim oversized observer frames to fit instead of dropping (#1072) perf(ci): speed up PR CI wall clock and local dev builds (#1028) chore(deps): update react monorepo (#1048) Polish desktop visual details (#1067) ci: use running postgres for pgschema desired-state planning (#1070) fix(desktop): anchor active-turn badge to skew-corrected agent start (#1068) feat(desktop): add configurable transport reconnect hook (#1059) Add automatic database migrations (#988) Add composer spoiler formatting (#1055) feat(desktop): in-channel and in-thread unread indicators (#1008) perf(timeline): gate heavy message render behind useDeferredValue (#1022) Add animated profile avatars (#1031) Polish direct message and members modals (#1054) Polish huddles UI (#1041) Fix video review comments in threads (#1056) Polish message reaction tray (#1002) Refine app loading skeletons (#1001) ... Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com> # Conflicts: # desktop/src-tauri/Cargo.lock
…fleger/persona-instantiation * origin/wpfleger/persona-events: (50 commits) chore(release): release version 0.3.24 (#1074) feat(desktop): refine thread-unread badge to two-token form (#1069) fix(buzz): prevent reconnect storms from reaped ephemeral channels (#1071) fix(buzz-acp): trim oversized observer frames to fit instead of dropping (#1072) perf(ci): speed up PR CI wall clock and local dev builds (#1028) chore(deps): update react monorepo (#1048) Polish desktop visual details (#1067) ci: use running postgres for pgschema desired-state planning (#1070) fix(desktop): anchor active-turn badge to skew-corrected agent start (#1068) feat(desktop): add configurable transport reconnect hook (#1059) Add automatic database migrations (#988) Add composer spoiler formatting (#1055) feat(desktop): in-channel and in-thread unread indicators (#1008) perf(timeline): gate heavy message render behind useDeferredValue (#1022) Add animated profile avatars (#1031) Polish direct message and members modals (#1054) Polish huddles UI (#1041) Fix video review comments in threads (#1056) Polish message reaction tray (#1002) Refine app loading skeletons (#1001) ... Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
* origin/main: (26 commits) fix(desktop): restore timeline zoom via rem tokens + chat-as-base type scale (#1052) fix(release): format changelog as linked markdown bullets (#1075) chore(release): release version 0.3.24 (#1074) feat(desktop): refine thread-unread badge to two-token form (#1069) fix(buzz): prevent reconnect storms from reaped ephemeral channels (#1071) fix(buzz-acp): trim oversized observer frames to fit instead of dropping (#1072) perf(ci): speed up PR CI wall clock and local dev builds (#1028) chore(deps): update react monorepo (#1048) Polish desktop visual details (#1067) ci: use running postgres for pgschema desired-state planning (#1070) fix(desktop): anchor active-turn badge to skew-corrected agent start (#1068) feat(desktop): add configurable transport reconnect hook (#1059) Add automatic database migrations (#988) Add composer spoiler formatting (#1055) feat(desktop): in-channel and in-thread unread indicators (#1008) perf(timeline): gate heavy message render behind useDeferredValue (#1022) Add animated profile avatars (#1031) Polish direct message and members modals (#1054) Polish huddles UI (#1041) Fix video review comments in threads (#1056) ... Co-authored-by: Taylor Ho <taylorkmho@gmail.com> Signed-off-by: Taylor Ho <taylorkmho@gmail.com> # Conflicts: # desktop/src/features/messages/lib/useRichTextEditor.ts # desktop/src/features/messages/ui/MessageComposer.tsx
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-markdownparse. 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 theTimelineMessageListprop) read the same deferred snapshot so scroll math never tears against the painted DOM — this also closes a latent race where the deep-linkscrollIntoViewcould 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_MESSAGESmodule-level constant anddeferredMessages = useDeferredValue(messages, EMPTY_MESSAGES). Switched the scroll manager,showMessageList/showGenericEmptyflags, and theTimelineMessageList messagesprop to readdeferredMessagesso the deferred snapshot drives everything consistently. Wired the previously-unused pending flag to a subtleopacity-60dim while a deferred render is in flight, so the list reads as streaming-in rather than frozen.Reproduction Steps
Notes
pnpm typecheck✅ andbiome lint✅ both pass.