Skip to content

[web] optimize UI rerender boundaries - #404

Merged
2witstudios merged 4 commits into
masterfrom
codex/ui-rerender-optimizations
Feb 6, 2026
Merged

2witstudios merged 4 commits into
masterfrom
codex/ui-rerender-optimizations

Conversation

@2witstudios

@2witstudios 2witstudios commented Feb 6, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • reduce unnecessary rerenders across dashboard, sidebars, page views, and notifications by switching broad store/context subscriptions to selector-based reads
  • tighten memoization boundaries and prop stability in heavy views (chat/channel/task/document/canvas plus right sidebar) to keep streaming and navigation states mounted without flashing
  • optimize page tree interaction paths: dragover state updates are now idempotent and socket-driven content-updated events use granular node updates with safe revalidation fallback
  • split global chat consumption so history uses conversation-only context while chat surfaces keep shared streaming context

Validation

  • pnpm --filter web typecheck (fails in this worktree: node_modules missing, tsc not found)
  • pnpm --filter web lint (fails in this worktree: node_modules missing, next not found)

Summary by CodeRabbit

  • New Features

    • Added conversation management context for improved chat organization.
    • Enhanced message edit/delete with optimistic updates and automatic recovery on errors.
  • Bug Fixes

    • Fixed duplicate message chunks in AI chat streaming.
    • Improved real-time message deduplication for socket events.
  • Performance

    • Optimized component rendering across dashboard and chat interfaces.
    • Improved drag-and-drop responsiveness in page organization.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@coderabbitai

coderabbitai Bot commented Feb 6, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Rate limit exceeded

@2witstudios has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 7 minutes and 24 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📝 Walkthrough

Walkthrough

This PR comprehensively refactors state management patterns across the application, replacing object destructuring with granular Zustand selectors, applies React.memo memoization to optimize component re-renders, prevents duplicate AI streaming start chunks, enhances drag-and-drop logic defensiveness, and refactors hooks for better stability and performance.

Changes

Cohort / File(s) Summary
AI Streaming Endpoint
apps/web/src/app/api/ai/chat/route.ts
Adds startChunkSent flag to ensure initial server-generated message start chunk is sent exactly once; filters subsequent start chunks in streaming loop to prevent client/server message ID misalignment.
Store Selector Refactoring — Drive Store
apps/web/src/app/dashboard/[driveId]/*, apps/web/src/app/dashboard/trash/page.tsx, apps/web/src/components/dialogs/{CopyPageDialog,MovePageDialog}.tsx
Replaced destructured useDriveStore returns with individual selectors (drives, isLoading, fetchDrives) to reduce re-renders via granular state subscriptions. Dependency arrays updated to include fetchDrives where necessary.
Store Selector Refactoring — Socket Store
apps/web/src/app/dashboard/inbox/channel/[pageId]/page.tsx, apps/web/src/components/ai/shared/AiUsageMonitor.tsx, apps/web/src/components/billing/UsageCounter.tsx, apps/web/src/components/layout/middle-content/page-views/...
Replaced object destructuring of useSocketStore with individual selectors for socket, connectionStatus, and connect to enable finer-grained subscriptions.
Store Selector Refactoring — Notification & Document Stores
apps/web/src/app/notifications/page.tsx, apps/web/src/components/notifications/{NotificationBell,NotificationDropdown}.tsx
Converted bulk destructuring of useNotificationStore and useSocketStore to per-property selectors, improving memoization and reducing unnecessary re-renders.
Store Selector Refactoring — Multi-Select & Layout Stores
apps/web/src/components/layout/left-sidebar/page-tree/{MultiSelectToolbar,PageTreeItem}.tsx, apps/web/src/components/layout/middle-content/content-header/EditorToggles.tsx
Replaced aggregated store destructuring with individual selectors for granular state access and improved component re-render behavior.
Component Memoization — Simple
apps/web/src/components/layout/right-sidebar/ai-assistant/{SidebarChatTab,SidebarHistoryTab}.tsx, apps/web/src/components/layout/middle-content/page-views/canvas/CanvasPageView.tsx
Wrapped default exports in React.memo with custom comparators or simple memoization to optimize re-render behavior on prop changes.
Component Memoization — Complex
apps/web/src/components/layout/middle-content/page-views/{ai-page/AiChatView,channel/ChannelView,document/DocumentView,task-list/TaskListView}.tsx, apps/web/src/components/layout/right-sidebar/index.tsx
Applied React.memo with custom comparators and replaced store destructuring with selectors; AiChatView adds buildFreshPageContext for on-demand page context construction; ChannelView introduces socket event wiring and permissions; RightPanel refactors agent state sourcing.
Drag-and-Drop Refactoring
apps/web/src/components/layout/left-sidebar/page-tree/PageTree.tsx
Refactored HTML5 drag handlers (handleFileDragEnter, handleFileDragLeave, handleFileDragOver, handleFileDropEvent) to be defensive and idempotent; added guards to prevent unnecessary state updates and improved dragStartPos initialization logic.
Page Header & Save Status
apps/web/src/components/layout/middle-content/content-header/index.tsx
Introduced new memoized DocumentSaveStatus component driven by useDocumentManagerStore; added showSaveStatus flag for conditional rendering; memoized page resolution and handleDownload callback; replaced inline SaveStatusIndicator.
Global Dashboard Assistant
apps/web/src/components/layout/middle-content/page-views/dashboard/GlobalAssistantView.tsx
Replaced multi-property store destructuring with individual selectors; introduced latestAgentMessagesRef and latestGlobalMessagesRef to track live message state and prevent stale closures in concurrent updates; wrapped export in React.memo.
Context & Hook Refactoring
apps/web/src/contexts/GlobalChatContext.tsx, apps/web/src/hooks/page-agents/usePageAgentSidebarState.ts
Added new GlobalChatConversationContext and useGlobalChatConversation hook to separate conversation-scoped data from live message streams; converted usePageAgentSidebarState to use granular selectors and useCallback wrappers.
Advanced Hook Optimizations
apps/web/src/hooks/usePageTreeSocket.ts
Introduced socketId constant for stable dependencies, treePageIdsRef to track tree page IDs, handleContentUpdatedEvent to process server content updates with debounced revalidation, and improved socket listener lifecycle management.
Utility Hook Refactoring
apps/web/src/hooks/{useGlobalDriveSocket,useTabSync}.ts, apps/web/src/lib/ai/shared/hooks/useMessageActions.ts
useGlobalDriveSocket switched to selector-based fetchDrives access; useTabSync replaced selectors with single getState() call inside effect; useMessageActions introduced SetMessagesAction type for functional updates with safer rollback semantics in edit/delete flows.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~65 minutes

Possibly related PRs

  • #96: Modifies the same API streaming handler (apps/web/src/app/api/ai/chat/route.ts) for control-flow updates.
  • #395: Addresses the same issue of enforcing single start-of-stream messages and preventing duplicate chunks in AI streaming.
  • #275: Fixes message synchronization race conditions in GlobalAssistantView and GlobalChatContext using ref-based state tracking.

Suggested labels

codex, refactoring, performance, state-management

Poem

🐰 A hop through selectors so fine,
Memoized components in line,
Streams send their chunks just once more,
Drag-and-drop now idempotent and sure,
State management refined, pristine! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.30% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title '[web] optimize UI rerender boundaries' clearly and accurately summarizes the main objective of the pull request: optimizing UI re-render boundaries through selector-based reads and memoization.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch codex/ui-rerender-optimizations

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

…ptimizations

# Conflicts:
#	apps/web/src/components/layout/middle-content/page-views/dashboard/GlobalAssistantView.tsx
#	apps/web/src/lib/ai/shared/hooks/useMessageActions.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
apps/web/src/components/dialogs/MovePageDialog.tsx (1)

103-115: ⚠️ Potential issue | 🟠 Major

availableDrives creates a new reference every render, risking an infinite effect loop.

availableDrives (line 69) is recomputed via .filter() on every render, producing a new array reference each time. Because it's listed as a dependency at line 115, this effect re-fires every render. Inside, setExpandedNodes(new Set()) always creates a new object, which triggers another re-render — forming an infinite loop.

This is pre-existing and not introduced by this PR, but the tighter selector boundaries make it more likely to surface in practice. Consider wrapping availableDrives in useMemo or removing it from the dependency array.

Suggested fix
+  const availableDrives = useMemo(
+    () => drives.filter(
+      (d: Drive) => d.isOwned || d.role === "OWNER" || d.role === "ADMIN"
+    ),
+    [drives]
+  );
-  const availableDrives = drives.filter(
-    (d: Drive) => d.isOwned || d.role === "OWNER" || d.role === "ADMIN"
-  );

(Also add useMemo to the React import at line 3.)

apps/web/src/lib/ai/shared/hooks/useMessageActions.ts (1)

220-224: ⚠️ Potential issue | 🟠 Major

setMessages(filteredMessages) uses a stale snapshot — inconsistent with the functional-updater pattern used elsewhere.

filteredMessages is derived from the messages closure captured at callback creation time. The sequential del calls above (lines 206–218) can take significant time, during which new messages may arrive. Setting messages to the stale snapshot will clobber them.

Use a functional updater for consistency with handleEdit / handleDelete:

Proposed fix
-      // Remove them from state
-      const filteredMessages = messages.filter(
-        (m) => !assistantMessagesToDelete.some((toDelete) => toDelete.id === m.id)
-      );
-      setMessages(filteredMessages);
+      // Remove them from state
+      setMessages((previousMessages) =>
+        previousMessages.filter(
+          (m) => !assistantMessagesToDelete.some((toDelete) => toDelete.id === m.id)
+        )
+      );
🤖 Fix all issues with AI agents
In
`@apps/web/src/components/layout/left-sidebar/page-tree/MultiSelectToolbar.tsx`:
- Line 35: The selector creates a new array on every store update which forces
re-renders; fix by passing Zustand's shallow equality function to the hook so
the array is compared shallowly instead of by reference. Import useShallow from
'zustand/shallow' (or 'zustand/react/shallow' depending on your setup) and call
useMultiSelectStore with the same selector
(Array.from(state.selectedPages.values())) as the first arg and useShallow as
the second argument; this stabilizes selectedPages so handleBulkDelete and the
props passed into MovePageDialog and CopyPageDialog won't be recreated
unnecessarily.

In
`@apps/web/src/components/layout/middle-content/page-views/dashboard/GlobalAssistantView.tsx`:
- Around line 311-320: The inline setMessages updater functions are recreated
every render causing handleEdit, handleDelete, and handleRetry in
useMessageActions to be unstable; wrap those updater callbacks in useCallback
(referencing latestAgentMessagesRef and latestGlobalMessagesRef) so they are
stable across renders and only update when the refs change, then use the
memoized callbacks as the setMessages dependency for handleEdit, handleDelete,
and handleRetry to prevent unnecessary handler recreation.
🧹 Nitpick comments (12)
apps/web/src/components/dialogs/CopyPageDialog.tsx (1)

105-115: availableDrives in the dependency array triggers the effect on every render.

availableDrives is computed via .filter() on line 71, which creates a new array reference each render. As a useEffect dependency, this causes the effect to fire repeatedly — potentially resetting selectedDriveId and selectedParentId during user interaction. This is pre-existing but undermines the re-render optimizations introduced here.

Consider memoizing availableDrives with useMemo:

♻️ Proposed fix
+import { useState, useEffect, useCallback, useMemo } from "react";
-import { useState, useEffect, useCallback } from "react";
-  const availableDrives = drives.filter(
-    (d: Drive) => d.isOwned || d.role === "OWNER" || d.role === "ADMIN"
-  );
+  const availableDrives = useMemo(
+    () => drives.filter(
+      (d: Drive) => d.isOwned || d.role === "OWNER" || d.role === "ADMIN"
+    ),
+    [drives]
+  );
apps/web/src/hooks/useTabSync.ts (1)

38-38: Consider using the store's selectActiveTab selector for consistency.

The store already exports selectActiveTab(state) which does the same lookup. Using it here would keep the logic DRY and automatically benefit from any future changes to how the active tab is resolved.

+import { useTabsStore, selectActiveTab } from '@/stores/useTabsStore';
 ...
-    const activeTab = state.tabs.find((t) => t.id === state.activeTabId);
+    const activeTab = selectActiveTab(state);

This is purely a consistency nit — the current code is functionally equivalent.

apps/web/src/components/layout/middle-content/page-views/channel/ChannelView.tsx (2)

438-443: Custom memo comparator is correct but may drift.

The comparator only checks page.id and page.driveId, which matches all page properties currently consumed in this component. This is a valid optimization — the heavy internal state (messages, socket listeners, effects) makes skipping parent-driven rerenders worthwhile.

Consider adding a brief comment above the comparator noting that it must be updated if additional page fields are read in the future, to guard against staleness:

 export default memo(
   ChannelView,
+  // Only re-render when the channel identity changes.
+  // Update this comparator if additional page fields are consumed.
   (prevProps, nextProps) =>
     prevProps.page.id === nextProps.page.id &&
     prevProps.page.driveId === nextProps.page.driveId
 );

234-271: messages in the dependency array defeats memoization for handleRemoveReaction.

Since messages is a dependency, this callback is recreated on every message arrival, which propagates a new onRemoveReaction prop to every MessageReactions instance — exactly the kind of rerender cascade this PR aims to eliminate.

You can remove the messages dependency by capturing the removed reaction inside the state updater:

♻️ Suggested refactor
  const handleRemoveReaction = useCallback(async (messageId: string, emoji: string) => {
    if (!user) return;

-   // Optimistic update
-   const removedReaction = messages
-     .find((m) => m.id === messageId)
-     ?.reactions?.find((r) => r.emoji === emoji && r.userId === user.id);
-
-   setMessages((prev) =>
-     prev.map((m) => {
-       if (m.id !== messageId) return m;
-       return {
-         ...m,
-         reactions: (m.reactions || []).filter(
-           (r) => !(r.emoji === emoji && r.userId === user.id)
-         ),
-       };
-     })
-   );
+   // Capture the removed reaction inside the updater so `messages`
+   // doesn't need to be a dependency.
+   let removedReaction: Reaction | undefined;
+   setMessages((prev) =>
+     prev.map((m) => {
+       if (m.id !== messageId) return m;
+       removedReaction ??= m.reactions?.find(
+         (r) => r.emoji === emoji && r.userId === user.id
+       );
+       return {
+         ...m,
+         reactions: (m.reactions || []).filter(
+           (r) => !(r.emoji === emoji && r.userId === user.id)
+         ),
+       };
+     })
+   );

    try {
      await del(`/api/channels/${page.id}/messages/${messageId}/reactions`, { emoji });
    } catch {
      // Revert optimistic update on error
      if (removedReaction) {
        setMessages((prev) =>
          prev.map((m) => {
            if (m.id !== messageId) return m;
            return {
              ...m,
              reactions: [...(m.reactions || []), removedReaction],
            };
          })
        );
      }
      toast.error('Failed to remove reaction');
    }
- }, [page.id, user, messages]);
+ }, [page.id, user]);
apps/web/src/components/layout/left-sidebar/page-tree/MultiSelectToolbar.tsx (1)

54-92: handleBulkDelete reads stale snapshot of selectedPages on invocation.

selectedPages and selectedCount are captured in the closure at callback creation time (Lines 56–57). Because selectedPages is derived from the selector on Line 35 (and will be a new array each render as noted above), the values used inside the callback are from the render that last recreated it — which may not match the store's current state if an update races in between.

A more resilient pattern is to read the store imperatively at call time:

Suggested fix
  const handleBulkDelete = useCallback(async () => {
    setIsDeleting(true);
-   const count = selectedCount;
-   const pageIds = selectedPages.map((p) => p.id);
+   const { selectedPages: currentPages } = useMultiSelectStore.getState();
+   const count = currentPages.size;
+   const pageIds = Array.from(currentPages.keys());
    const toastId = toast.loading(
      `Moving ${count} ${count === 1 ? "page" : "pages"} to trash...`
    );
    ...
- }, [selectedCount, selectedPages, exitMultiSelectMode, onMutate]);
+ }, [exitMultiSelectMode, onMutate]);
apps/web/src/lib/ai/shared/hooks/useMessageActions.ts (2)

69-136: messages in the dependency array causes handleEdit identity to change on every message update.

Since messages is in the deps (line 136), and handleEdit / handleDelete / handleRetry all depend on it, their identities change on every incoming message. If any of these callbacks are passed as props to memoized children, it defeats React.memo.

The messages dependency is only needed to capture originalMessage for rollback (line 73). Consider using a ref to hold the latest messages so the callback identity remains stable:

const messagesRef = useRef(messages);
messagesRef.current = messages;

Then read messagesRef.current inside the callback instead of messages, and remove messages from the dep array.

This is optional given the PR's scope, but it would align with the stated goal of tightening rerender boundaries.


193-236: Sequential del calls in handleRetry could be parallelized.

Lines 206–218 await each delete one at a time. Since these are independent network calls, Promise.allSettled would reduce total latency for retry, especially when multiple assistant messages need cleanup.

Suggested change
-      // Delete them from the database
-      for (const msg of assistantMessagesToDelete) {
-        try {
-          if (isAgentMode) {
-            await del(
-              `/api/ai/page-agents/${agentId}/conversations/${conversationId}/messages/${msg.id}`
-            );
-          } else {
-            await del(`/api/ai/global/${conversationId}/messages/${msg.id}`);
-          }
-        } catch (error) {
-          console.error('Failed to delete old assistant message:', error);
-        }
-      }
+      // Delete them from the database (in parallel — calls are independent)
+      await Promise.allSettled(
+        assistantMessagesToDelete.map((msg) => {
+          const url = isAgentMode
+            ? `/api/ai/page-agents/${agentId}/conversations/${conversationId}/messages/${msg.id}`
+            : `/api/ai/global/${conversationId}/messages/${msg.id}`;
+          return del(url).catch((error) => {
+            console.error('Failed to delete old assistant message:', error);
+          });
+        })
+      );
apps/web/src/components/layout/right-sidebar/ai-assistant/SidebarHistoryTab.tsx (1)

392-392: Consider whether default shallow compare is sufficient for selectedAgent prop.

React.memo(SidebarHistoryTab) uses the default shallow comparison. The selectedAgent prop is an AgentInfo object — if the parent creates a new object reference on each render (even with same data), memo won't help. Verify that parents pass a stable reference (e.g., from a Zustand selector, which returns the same reference if the value hasn't changed).

apps/web/src/contexts/GlobalChatContext.tsx (1)

272-295: initialMessages in deps may cause unnecessary re-renders for history-only consumers.

The conversationContextValue memo includes initialMessages in its dependency array. When a conversation is loaded, initialMessages gets a new array reference, causing the conversation context to update. Consumers like SidebarHistoryTab that only use currentConversationId and the action functions will still re-render despite not using initialMessages.

This is a minor gap — the big win (avoiding streaming re-renders) is preserved. If you want to further reduce re-renders, you could split into an even more granular context or have consumers use useMemo/selectors on the context value. But this is likely fine as-is.

apps/web/src/components/layout/middle-content/page-views/ai-page/AiChatView.tsx (1)

302-348: buildFreshPageContext — type cast is incomplete for SWR 2.x cache shape.

Line 305 casts the SWR cache entry as { data?: TreePage[] } | undefined, but SWR 2.x's cache.get(key) returns { data, error, isValidating } | undefined. The cast only captures data, missing error and isValidating properties.

Since the code only accesses .data, the cast works functionally. However, add a brief inline comment noting the complete SWR cache shape for clarity:

// SWR 2.x cache shape: { data?, error?, isValidating? }
apps/web/src/hooks/usePageTreeSocket.ts (1)

69-78: Double-revalidation path for events missing either pageId or socketId.

Lines 69-72 trigger debouncedRevalidate() when pageId is absent, and lines 76-78 trigger it again when socketId is absent. If an event arrives with both fields missing, only the first branch fires (early return), which is fine. But for events that do have a pageId but no socketId (e.g., server-originated updates that include the target page), you fall back to a full tree revalidation even though the pageId is known and present in the tree. This is intentional per the comment (strict correctness for server-side updates), but it means tool-originated or server-side page renames won't get the granular updateNode path — they'll always do a full refetch.

If that's acceptable, a brief inline comment clarifying this trade-off for future maintainers would help.

apps/web/src/components/layout/right-sidebar/index.tsx (1)

85-92: handleTabChange is recreated every render — consider useCallback for consistency.

Given that this PR is specifically about tightening memoization boundaries and RightPanel is now wrapped in memo, stabilizing handleTabChange with useCallback would be consistent with the optimization goals. It's passed to Tabs.onValueChange and, while unlikely to cause measurable perf issues, it would prevent Radix's Tabs from seeing a new function prop each render.

♻️ Proposed stabilization
-  const handleTabChange = (tab: string) => {
-    const validTab = tab as SidebarTab;
-    if (isDashboardContext) {
-      setDashboardActiveTab(validTab);
-    } else {
-      setLocalActiveTab(validTab);
-    }
-  };
+  const handleTabChange = useCallback((tab: string) => {
+    const validTab = tab as SidebarTab;
+    if (isDashboardContext) {
+      setDashboardActiveTab(validTab);
+    } else {
+      setLocalActiveTab(validTab);
+    }
+  }, [isDashboardContext, setDashboardActiveTab]);

Comment thread apps/web/src/components/layout/left-sidebar/page-tree/MultiSelectToolbar.tsx Outdated
- Wrap availableDrives in useMemo in MovePageDialog and CopyPageDialog
- Use messagesRef pattern in useMessageActions to stabilize callback identities
- Switch handleRetry to functional updater and parallelize delete calls
- Add useShallow for MultiSelectToolbar selector, read store imperatively in handleBulkDelete
- Stabilize GlobalAssistantView setMessages callbacks with useCallback
- Capture removedReaction inside state updater in ChannelView handleRemoveReaction
- Use selectActiveTab selector in useTabSync for consistency
- Wrap handleTabChange in useCallback in right-sidebar

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@2witstudios
2witstudios merged commit fdfde7a into master Feb 6, 2026
3 checks passed
@2witstudios
2witstudios deleted the codex/ui-rerender-optimizations branch February 6, 2026 13:26
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