Skip to content

perf(site): split sidebar context to prevent cascade re-renders#23726

Draft
DanielleMaywood wants to merge 1 commit intomainfrom
daniellemaywood/sidebar-rerender-fix
Draft

perf(site): split sidebar context to prevent cascade re-renders#23726
DanielleMaywood wants to merge 1 commit intomainfrom
daniellemaywood/sidebar-rerender-fix

Conversation

@DanielleMaywood
Copy link
Copy Markdown
Contributor

🤖 This PR was written by Coder Agent on behalf of Danielle Maywood 🤖

Every WebSocket event (status change, title update) was re-rendering all ~100 ChatTreeNode components in the sidebar — 500–700 re-renders per event — because they all subscribed to a single ChatTreeContext whose value changed on every update.

Split into two contexts:

  • ChatTreeCallbacksContext — stable callbacks and config that do not change on WS events
  • ChatTreeDataContext — tree data that changes when chats update

ChatTreeNode is now memo()-wrapped and receives per-node data as props (error state, expanded, archiving, etc.) instead of reading from context. A thin ChatTreeChildren bridge component subscribes to the data context for recursive child rendering, computing per-child props so memo() can skip unchanged nodes.

When a single chat changes status, only that node and its direct parent re-render instead of the entire sidebar tree.

Profiling context

Before (React DevTools Profiler)

Commit Duration Re-renders Trigger
#95 85ms 671 AgentsPage
#59 58ms 489 AgentsPage
#61 50ms 598 AgentsPage

ChatTreeNode at 50ms total self-time, with NavLink, DropdownMenu, MenuPortal cascading from every node.

Root cause

ChatTreeContext bundled 18+ fields into one object. chatTree, chatById, visibleChatIDs were recomputed from the chats array on every WS event (new pages.flat() reference from react-query). Every ChatTreeNode called useChatTree() → context change bypasses memo() → full cascade.

Fix approach

The store already preserves ChatMessage object identity for unchanged chats. By moving per-node derived data into props, memo() on ChatTreeNode sees stable chat references for unaffected nodes and skips re-render. Only the node whose chat object changed (new ref from cache updater) re-renders.

@github-actions github-actions bot added the community Pull Requests and issues created by the community. label Mar 27, 2026
Copy link
Copy Markdown
Contributor Author

@DanielleMaywood DanielleMaywood left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 This review was written by Coder Agent on behalf of Danielle Maywood 🤖

Review by Pluto 🐕 and team — 6 specialist reviewers, 22 hypotheses tested, 4 fact-checked.

Overall this is a well-designed change. The context split is the right architecture and the bridge component pattern is sound. Three items to address, one of which undermines the stated perf goal.

interface ChatTreeNodeProps {
readonly chat: Chat;
readonly isChildNode: boolean;
readonly childChats: readonly Chat[];
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1 Important] childChats array prop defeats memo() — the perf win this PR is going for doesn't work yet

childChats is only used at line 519 for const hasChildren = childChats.length > 0; — a boolean derivation. The actual child rendering is handled by <ChatTreeChildren parentChatId={chatID} /> (line 744), which subscribes to the data context independently.

All three call sites compute childChats via .map().filter(), which always returns a new array reference. memo() uses shallow equality, so the comparison fails on every render and ChatTreeNode never bails out.

Verified against the React Compiler output: the compiler emits _c(95) for ChatTreeNode — 95 internal cache slots that make unchanged renders cheap but not free. The memo() wrapper is preserved in compiled output but defeated by the new array reference. Skipping 95 cache checks + vDOM production + reconciliation of NavLink/DropdownMenu/Tooltip for ~99 unchanged nodes per WS event is meaningful.

Suggestion: replace childChats: readonly Chat[] with hasChildren: boolean. Compute the boolean at each call site. With all props as primitives or structurally-shared references, memo() can actually bail out.

Also: memo() on SortableChatTreeNode (line 751) is useless — it calls useChatTreeData() internally (line 768), and context subscriptions bypass memo(). It re-renders on every data context change regardless. The compiler's _c(34) internal cache handles the optimization there. Consider removing it.

🤖 This comment was written by Coder Agent on behalf of Danielle Maywood 🤖

// Compute per-node data from the data context so the
// memo'd ChatTreeNode can skip re-renders when its
// specific slice of data hasn't changed.
const childIDs = (chatTree.childrenById.get(chat.id) ?? []).filter(
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2 Improvement] Per-node prop derivation duplicated in three rendering sites

This same five-step pattern — filter visible child IDs → resolve to Chat objects → compute isExpanded → compute errorReason → compute archiving/regenerating flags — is repeated in ChatTreeChildren (lines 459–489), here in SortableChatTreeNode (lines 797–831), and in the time-grouped section (lines 1250–1289).

Adding a new prop to ChatTreeNodeProps requires updating all three sites in lockstep, and variable naming already diverges (childChatsForNode vs childChats vs nodeChildChats).

Suggestion: extract a shared helper, e.g. function computeNodeProps(chatId: string, chat: Chat, data: ChatTreeDataValue) that returns the per-node prop slice. All three sites call it and spread the result.

🤖 This comment was written by Coder Agent on behalf of Danielle Maywood 🤖

}
/>
);
})}{" "}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3 Nit] Stray {" "} text node

This {" "} wasn't in the original code — it was introduced during the refactor. It creates an anonymous flex item inside the flex flex-col gap-0.5 container. Visually invisible but accidental DOM content. Prettier doesn't produce or remove it.

Suggested change
})}{" "}
})}

🤖 This comment was written by Coder Agent on behalf of Danielle Maywood 🤖

The ChatTreeContext bundled both stable callbacks and frequently-
changing data into a single context. Every WebSocket event (status
change, title update) created a new context value, forcing all
~100 ChatTreeNode components to re-render — 500-700 component
re-renders per event.

Split into ChatTreeCallbacksContext (stable callbacks + config)
and ChatTreeDataContext (tree data that changes on WS events).
ChatTreeNode is now memo()-wrapped and receives per-node data as
props instead of reading from context. A thin ChatTreeChildren
bridge component subscribes to the data context for recursive
child rendering, computing per-child props so memo() can skip
unchanged nodes.

When a single chat status changes, only that node re-renders
instead of the entire sidebar tree.
@DanielleMaywood DanielleMaywood force-pushed the daniellemaywood/sidebar-rerender-fix branch from 763bdd4 to b8f62ee Compare March 27, 2026 21:16
@DanielleMaywood DanielleMaywood removed the community Pull Requests and issues created by the community. label Mar 27, 2026
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