Skip to content

🪢 refactor: Eliminate Unnecessary Re-renders During Message Streaming#12454

Merged
danny-avila merged 23 commits into
devfrom
claude/hopeful-haslett
Mar 29, 2026
Merged

🪢 refactor: Eliminate Unnecessary Re-renders During Message Streaming#12454
danny-avila merged 23 commits into
devfrom
claude/hopeful-haslett

Conversation

@danny-avila

@danny-avila danny-avila commented Mar 29, 2026

Copy link
Copy Markdown
Owner

Summary

I systematically eliminated cascading re-renders during message streaming across the message list and chat input areas. The core problem was that useChatContext() subscriptions inside React.memo'd components bypassed memoization — when isSubmitting or other ChatContext values changed during streaming, every component subscribing to the context re-rendered regardless of memo wrappers. The same pattern repeated with useFileHandling() and unmemoized return values from hooks like useAddedResponse().

  • Introduce TMessageChatContext type and a stable context object pattern using useMemo with a getter-backed isSubmitting (ref-based) so the object reference stays stable across streaming updates while callbacks can still read the current value at call-time.
  • Remove useChatContext() from useMessageActions — accept a stable chatContext parameter instead, preventing context-triggered re-renders that bypass React.memo on MessageRender and ContentRender.
  • Stabilize handleScroll in useMessageProcess by using a ref for isSubmitting instead of including it in useCallback deps.
  • Add custom areEqual comparators to MessageRender and ContentRender memo wrappers that compare the message prop by key fields (messageId, text, depth, children.length, etc.) instead of reference equality, since buildTree creates new objects for all messages on every streaming chunk.
  • Move effectiveIsSubmitting computation (false for non-latest messages) into the Message and MessageContent wrapper components so non-latest messages receive a stable false prop.
  • Wrap StopButton, AudioRecorder, CollapseChat, and TextareaHeader in React.memo.
  • Remove useChatContext() from FileFormChat and AttachFileMenu by switching to the existing useFileHandlingNoChatContext and useSharePointFileHandlingNoChatContext variants, passing file state as explicit props from ChatForm.
  • Stabilize handleStopGenerating and newConversation in ChatFormWrapper via ref-based callbacks so StopButton and Mention receive stable function references.
  • Create stableConversation via selective useMemo in ChatFormWrapper keyed on rendering-relevant fields only, preventing re-renders from unrelated conversation metadata updates like title generation.
  • Extract useChatContext() subscription from ChatForm into a ChatFormWrapper that passes individual stable values as props, allowing ChatForm's memo to actually prevent re-renders.
  • Memoize useAddedResponse return value — it was returning a raw object literal on every render, causing AddedChatContext.Provider to trigger all consumers on every streaming chunk.
  • Re-export useFileHandlingNoChatContext from the hooks barrel and update test mocks for AttachFileMenu and AttachFileChat.
  • Add displayName to ChatForm for React DevTools visibility.

Change Type

  • Bug fix (non-breaking change which fixes an issue)

Testing

Profile with React DevTools Profiler during active message streaming:

  1. Open a conversation with multiple messages (mix of user and AI).
  2. Send a new message to trigger streaming.
  3. Record a Profiler session during the full streaming response.
  4. Verify non-latest MessageRender/ContentRender components show "Did not render" in the flamegraph.
  5. Verify AttachFileMenu, AttachFileChat, StopButton, AudioRecorder, FileFormChat, BadgeRow, SendButton show "Did not render" during streaming.
  6. Verify ChatForm re-renders are minimal (only on isSubmitting toggle, not every chunk).
  7. Verify all message actions (regenerate, copy, edit, continue, feedback) still work correctly after streaming completes.
  8. Verify file upload, stop generation, and audio recording still function.

Checklist

  • My code adheres to this project's style guidelines
  • I have performed a self-review of my own code
  • I have commented in any complex areas of my code
  • My changes do not introduce new warnings
  • Local unit tests pass with my changes

Copilot AI review requested due to automatic review settings March 29, 2026 03:45
@danny-avila
danny-avila marked this pull request as draft March 29, 2026 03:45

Copilot AI 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.

Pull request overview

Refactors message rendering to reduce unnecessary re-renders during streaming by preventing isSubmitting changes from cascading through memoized message components.

Changes:

  • Introduces a stable TMessageChatContext object passed from wrapper components into memoized render components, avoiding useChatContext subscriptions inside React.memo.
  • Switches isSubmitting usage to ref-backed access patterns (getter/ref) to keep callbacks stable and reduce prop churn.
  • Moves “effective isSubmitting only for latest message” computation into wrapper components and simplifies memoized components accordingly.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
client/src/hooks/Messages/useMessageProcess.tsx Stabilizes handleScroll against isSubmitting changes using a ref.
client/src/hooks/Messages/useMessageActions.tsx Stops subscribing to ChatContext; consumes a passed chatContext and ref-guards regenerate.
client/src/components/Messages/MessageContent.tsx Creates/passes stable chatContext; only passes isSubmitting to latest message.
client/src/components/Messages/ContentRender.tsx Accepts chatContext prop; relies on wrapper-computed effective isSubmitting.
client/src/components/Chat/Messages/ui/MessageRender.tsx Accepts chatContext prop; relies on wrapper-computed effective isSubmitting.
client/src/components/Chat/Messages/Message.tsx Creates/passes stable chatContext; only passes isSubmitting to latest message.
client/src/common/types.ts Adds TMessageChatContext type documenting the stable context/getter pattern.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread client/src/hooks/Messages/useMessageActions.tsx Outdated
Comment thread client/src/components/Chat/Messages/Message.tsx Outdated
Comment thread client/src/hooks/Messages/useMessageProcess.tsx Outdated
Defines a type for a stable context object that wrapper components
pass to memo'd message components, avoiding direct ChatContext
subscriptions that bypass React.memo during streaming.
useMessageActions previously called useChatContext() inside memo'd
components (MessageRender, ContentRender), bypassing React.memo
when isSubmitting changed during streaming. Now accepts a stable
chatContext param instead, using a ref for the isSubmitting guard
in regenerateMessage.

Also stabilizes handleScroll in useMessageProcess by using a ref
for isSubmitting instead of including it in useCallback deps.
Wrapper components (Message, MessageContent) now create a stable
chatContext object via useMemo with a getter-backed isSubmitting,
and compute effectiveIsSubmitting (false for non-latest messages).

This ensures MessageRender and ContentRender (both React.memo'd)
only re-render for the latest message during streaming, preventing
unnecessary re-renders of all prior messages and their SubRow,
HoverButtons, and SiblingSwitch children.
@danny-avila
danny-avila force-pushed the claude/hopeful-haslett branch from e457cda to 9378605 Compare March 29, 2026 15:48
…ders

buildTree creates new message objects on every streaming update for
ALL messages, not just the changed one. This defeats React.memo's
default shallow comparison since the message prop has a new reference
even when the content hasn't changed.

Custom areEqual comparators now compare message by key fields
(messageId, text, error, depth, children length, etc.) instead of
reference equality, preventing unnecessary re-renders of SubRow,
Files, HoverButtons and other children for non-latest messages.
- Wrap StopButton in React.memo
- Wrap AudioRecorder in React.memo, use ref for isSubmitting in
  onTranscriptionComplete callback to stabilize it
- Remove useChatContext() from FileFormChat (bypassed its memo
  during streaming), accept files/setFiles/setFilesLoading as
  props from ChatForm instead
ChatForm re-renders frequently during streaming (ChatContext changes).
This caused StopButton and AttachFileChat/AttachFileMenu to re-render
despite being memo'd, because their props were new references each time.

- Wrap handleStopGenerating in a ref-based stable callback so StopButton
  always receives the same function reference
- Create stableConversation via useMemo keyed on rendering-relevant
  fields only (conversationId, endpoint, agent_id, etc.), so
  AttachFileChat and FileFormChat don't re-render from unrelated
  conversation metadata updates (e.g., title generation)
…mChat

Both components used useFileHandling() which internally calls
useChatContext(), bypassing their React.memo wrappers and causing
re-renders on every streaming chunk.

Switch to useFileHandlingNoChatContext() which accepts file state
as parameters. The state (files, setFiles, setFilesLoading,
conversation) is passed down from ChatForm → AttachFileChat →
AttachFileMenu as props, keeping the memo chain intact.
- Re-export useFileHandlingNoChatContext from hooks barrel
- Import from ~/hooks instead of direct path for test compatibility
- Add useToastContext mock to @librechat/client in AttachFileMenu tests
  since useFileHandlingNoChatContext runs the core hook which needs it
- Add useFileHandlingNoChatContext to ~/hooks test mock
- Switch AttachFileMenu from useSharePointFileHandling (subscribes to
  ChatContext) to useSharePointFileHandlingNoChatContext with explicit
  file state props
- Memoize ChatForm textarea onFocus/onBlur handlers with useCallback
  to prevent TextareaAutosize re-renders (inline arrow functions and
  .bind() created new references on every ChatForm render)
- Update AttachFileMenu test mocks for new hook variants
ChatForm was re-rendering on every streaming chunk because it subscribed
to useChatContext() internally, and the ChatContext value changed
frequently during streaming.

Extract context subscription into a ChatFormWrapper that:
- Subscribes to useChatContext() (re-renders on every chunk, cheap)
- Stabilizes conversation via selective useMemo
- Stabilizes handleStopGenerating via ref-based callback
- Passes individual stable values as props to ChatForm

ChatForm (memo'd) now receives context values as props instead of
subscribing directly. Since individual values (files, setFiles,
isSubmitting, etc.) are stable references during streaming, ChatForm's
memo prevents re-renders entirely — it only re-renders when isSubmitting
actually toggles (2x per stream: start/end).
- Wrap newConversation in ref-based stable callback in ChatFormWrapper
  (was the remaining unstable prop causing ChatForm to re-render)
- Wrap CollapseChat in React.memo to prevent re-renders from parent
useAddedResponse returned a new object literal on every render,
causing AddedChatContext.Provider to trigger re-renders of all
consumers (including ChatForm) on every streaming chunk. Wrap in
useMemo so the context value stays referentially stable.
@danny-avila danny-avila changed the title refactor: prevent isSubmitting from triggering context re-renders refactor: Eliminate Unnecessary Re-renders During Message Streaming Mar 29, 2026
Finding 1: Switch AttachFile.tsx from useFileHandling to
useFileHandlingNoChatContext, closing the optimization hole for
standard (non-agent) chat endpoints.

Finding 2: Replace content reference equality with length comparison
in both memo comparators — safer against buildTree array reconstruction.

Finding 3: Add conversation?.model to stableConversation deps in
ChatFormWrapper so file uploads use the correct model after switches.

Finding 4/14: Fix stableNewConversation to explicitly return the
underlying call's result instead of discarding it via `as` cast.

Finding 5/6: Extract useMemoizedChatContext hook shared by Message.tsx
and MessageContent.tsx — eliminates ~70 lines of duplication and
stabilizes chatContext.conversation via selective useMemo to prevent
post-stream metadata updates from re-rendering all messages.

Finding 8: Use TMessage type for regenerate param instead of
Record<string, unknown>.

Finding 9: Use FileSetter alias in FileFormChat instead of inline type.

Finding 11: Fix pre-existing broken throttle in useMessageProcess —
was creating a new throttle instance per call, providing zero
deduplication. Now retains the instance via useMemo.

Finding 12: Initialize isSubmittingRef with chatContext.isSubmitting
instead of false for consistency.

Finding 13: Add ChatFormWrapper displayName.
@danny-avila

Copy link
Copy Markdown
Owner Author

@codex review

The length-based comparison (content?.length) missed updates within
existing content parts during streaming — text chunks update a part's
content without changing the array length, so the comparator returned
true and skipped re-renders for the latest message.

Reference equality (===) is correct here: buildTree preserves content
array references for unchanged messages via shallow spread, while
React Query gives the latest message a new reference when its content
updates during streaming.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 582ee0fb9d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread client/src/components/Messages/ContentRender.tsx Outdated
The local isSubmittingRef was stale for non-latest messages (which
don't re-render during streaming by design). chatContext.isSubmitting
is a getter backed by the wrapper's ref, so reading it at call-time
always returns the current value regardless of whether the component
has re-rendered.
@danny-avila

Copy link
Copy Markdown
Owner Author

@codex review

@danny-avila danny-avila changed the title refactor: Eliminate Unnecessary Re-renders During Message Streaming 🪢 refactor: Eliminate Unnecessary Re-renders During Message Streaming Mar 29, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d23b6d2e7a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread client/src/components/Chat/Messages/Message.tsx
HoverButtons uses isSubmitting via useGenerationsByLatest to disable
regenerate and hide edit buttons during streaming. Passing the
effective value (false for non-latest messages) re-enabled those
actions mid-stream, risking overlapping edits/regenerations.

Use chatContext.isSubmitting (getter, always returns current value)
for HoverButtons while keeping the effective value for rendering-only
UI (cursor, placeholder, streaming indicator).
@danny-avila
danny-avila marked this pull request as ready for review March 29, 2026 20:10
- Add isSubmitting to chatContext useMemo deps in useMemoizedChatContext
  so HoverButtons correctly updates when streaming starts/ends (2 extra
  re-renders per session, belt-and-suspenders for post-stream state)
- Change conversation?.messages?.length dep to boolean in ChatFormWrapper
  stableConversation — only need 0↔1+ transition for landing page check,
  not exact count on every message addition
- Add defensive comment at chatContext destructuring point in
  useMessageActions explaining why isSubmitting must not be destructured
- Remove dead mockUseFileHandling.mockReturnValue from AttachFileMenu tests
@danny-avila

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 58ef62c60e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread client/src/components/Chat/Messages/ui/MessageRender.tsx
- Extract complex expression (conversation?.messages?.length ?? 0) > 0
  to hasMessages variable for static analysis in ChatFormWrapper
- Add eslint-disable for intentional isSubmitting dep in
  useMemoizedChatContext (forces new chatContext reference on streaming
  start/end so HoverButtons re-renders)
@danny-avila
danny-avila merged commit 7e2b516 into dev Mar 29, 2026
7 checks passed
@danny-avila
danny-avila deleted the claude/hopeful-haslett branch March 29, 2026 21:05
jcbartle pushed a commit to jcbartle/LibreChat that referenced this pull request May 11, 2026
…danny-avila#12454)

* refactor: add TMessageChatContext type for stable context passing

Defines a type for a stable context object that wrapper components
pass to memo'd message components, avoiding direct ChatContext
subscriptions that bypass React.memo during streaming.

* perf: remove ChatContext subscription from useMessageActions

useMessageActions previously called useChatContext() inside memo'd
components (MessageRender, ContentRender), bypassing React.memo
when isSubmitting changed during streaming. Now accepts a stable
chatContext param instead, using a ref for the isSubmitting guard
in regenerateMessage.

Also stabilizes handleScroll in useMessageProcess by using a ref
for isSubmitting instead of including it in useCallback deps.

* perf: pass stable chatContext to memo'd message components

Wrapper components (Message, MessageContent) now create a stable
chatContext object via useMemo with a getter-backed isSubmitting,
and compute effectiveIsSubmitting (false for non-latest messages).

This ensures MessageRender and ContentRender (both React.memo'd)
only re-render for the latest message during streaming, preventing
unnecessary re-renders of all prior messages and their SubRow,
HoverButtons, and SiblingSwitch children.

* perf: add custom memo comparators to prevent message reference re-renders

buildTree creates new message objects on every streaming update for
ALL messages, not just the changed one. This defeats React.memo's
default shallow comparison since the message prop has a new reference
even when the content hasn't changed.

Custom areEqual comparators now compare message by key fields
(messageId, text, error, depth, children length, etc.) instead of
reference equality, preventing unnecessary re-renders of SubRow,
Files, HoverButtons and other children for non-latest messages.

* perf: memoize ChatForm children to prevent streaming re-renders

- Wrap StopButton in React.memo
- Wrap AudioRecorder in React.memo, use ref for isSubmitting in
  onTranscriptionComplete callback to stabilize it
- Remove useChatContext() from FileFormChat (bypassed its memo
  during streaming), accept files/setFiles/setFilesLoading as
  props from ChatForm instead

* perf: stabilize ChatForm child props to prevent cascading re-renders

ChatForm re-renders frequently during streaming (ChatContext changes).
This caused StopButton and AttachFileChat/AttachFileMenu to re-render
despite being memo'd, because their props were new references each time.

- Wrap handleStopGenerating in a ref-based stable callback so StopButton
  always receives the same function reference
- Create stableConversation via useMemo keyed on rendering-relevant
  fields only (conversationId, endpoint, agent_id, etc.), so
  AttachFileChat and FileFormChat don't re-render from unrelated
  conversation metadata updates (e.g., title generation)

* perf: remove ChatContext subscription from AttachFileMenu and FileFormChat

Both components used useFileHandling() which internally calls
useChatContext(), bypassing their React.memo wrappers and causing
re-renders on every streaming chunk.

Switch to useFileHandlingNoChatContext() which accepts file state
as parameters. The state (files, setFiles, setFilesLoading,
conversation) is passed down from ChatForm → AttachFileChat →
AttachFileMenu as props, keeping the memo chain intact.

* fix: update imports and test mocks for useFileHandlingNoChatContext

- Re-export useFileHandlingNoChatContext from hooks barrel
- Import from ~/hooks instead of direct path for test compatibility
- Add useToastContext mock to @librechat/client in AttachFileMenu tests
  since useFileHandlingNoChatContext runs the core hook which needs it
- Add useFileHandlingNoChatContext to ~/hooks test mock

* perf: fix remaining ChatForm streaming re-renders

- Switch AttachFileMenu from useSharePointFileHandling (subscribes to
  ChatContext) to useSharePointFileHandlingNoChatContext with explicit
  file state props
- Memoize ChatForm textarea onFocus/onBlur handlers with useCallback
  to prevent TextareaAutosize re-renders (inline arrow functions and
  .bind() created new references on every ChatForm render)
- Update AttachFileMenu test mocks for new hook variants

* refactor: add displayName to ChatForm for React DevTools

* perf: prevent ChatForm re-renders during streaming via wrapper pattern

ChatForm was re-rendering on every streaming chunk because it subscribed
to useChatContext() internally, and the ChatContext value changed
frequently during streaming.

Extract context subscription into a ChatFormWrapper that:
- Subscribes to useChatContext() (re-renders on every chunk, cheap)
- Stabilizes conversation via selective useMemo
- Stabilizes handleStopGenerating via ref-based callback
- Passes individual stable values as props to ChatForm

ChatForm (memo'd) now receives context values as props instead of
subscribing directly. Since individual values (files, setFiles,
isSubmitting, etc.) are stable references during streaming, ChatForm's
memo prevents re-renders entirely — it only re-renders when isSubmitting
actually toggles (2x per stream: start/end).

* perf: stabilize newConversation prop and memoize CollapseChat

- Wrap newConversation in ref-based stable callback in ChatFormWrapper
  (was the remaining unstable prop causing ChatForm to re-render)
- Wrap CollapseChat in React.memo to prevent re-renders from parent

* perf: memoize useAddedResponse return value

useAddedResponse returned a new object literal on every render,
causing AddedChatContext.Provider to trigger re-renders of all
consumers (including ChatForm) on every streaming chunk. Wrap in
useMemo so the context value stays referentially stable.

* perf: memoize TextareaHeader to prevent re-renders from ChatForm

* perf: address review findings for streaming render optimization

Finding 1: Switch AttachFile.tsx from useFileHandling to
useFileHandlingNoChatContext, closing the optimization hole for
standard (non-agent) chat endpoints.

Finding 2: Replace content reference equality with length comparison
in both memo comparators — safer against buildTree array reconstruction.

Finding 3: Add conversation?.model to stableConversation deps in
ChatFormWrapper so file uploads use the correct model after switches.

Finding 4/14: Fix stableNewConversation to explicitly return the
underlying call's result instead of discarding it via `as` cast.

Finding 5/6: Extract useMemoizedChatContext hook shared by Message.tsx
and MessageContent.tsx — eliminates ~70 lines of duplication and
stabilizes chatContext.conversation via selective useMemo to prevent
post-stream metadata updates from re-rendering all messages.

Finding 8: Use TMessage type for regenerate param instead of
Record<string, unknown>.

Finding 9: Use FileSetter alias in FileFormChat instead of inline type.

Finding 11: Fix pre-existing broken throttle in useMessageProcess —
was creating a new throttle instance per call, providing zero
deduplication. Now retains the instance via useMemo.

Finding 12: Initialize isSubmittingRef with chatContext.isSubmitting
instead of false for consistency.

Finding 13: Add ChatFormWrapper displayName.

* fix: revert content comparison to reference equality in memo comparators

The length-based comparison (content?.length) missed updates within
existing content parts during streaming — text chunks update a part's
content without changing the array length, so the comparator returned
true and skipped re-renders for the latest message.

Reference equality (===) is correct here: buildTree preserves content
array references for unchanged messages via shallow spread, while
React Query gives the latest message a new reference when its content
updates during streaming.

* fix: cancel throttled handleScroll on unmount and remove unused import

* fix: use chatContext getter directly in regenerateMessage callback

The local isSubmittingRef was stale for non-latest messages (which
don't re-render during streaming by design). chatContext.isSubmitting
is a getter backed by the wrapper's ref, so reading it at call-time
always returns the current value regardless of whether the component
has re-rendered.

* fix: remove unused useCallback import from useMemoizedChatContext

* fix: pass global isSubmitting to HoverButtons for action gating

HoverButtons uses isSubmitting via useGenerationsByLatest to disable
regenerate and hide edit buttons during streaming. Passing the
effective value (false for non-latest messages) re-enabled those
actions mid-stream, risking overlapping edits/regenerations.

Use chatContext.isSubmitting (getter, always returns current value)
for HoverButtons while keeping the effective value for rendering-only
UI (cursor, placeholder, streaming indicator).

* fix: address second review — stale HoverButtons, messages dep, cleanup

- Add isSubmitting to chatContext useMemo deps in useMemoizedChatContext
  so HoverButtons correctly updates when streaming starts/ends (2 extra
  re-renders per session, belt-and-suspenders for post-stream state)
- Change conversation?.messages?.length dep to boolean in ChatFormWrapper
  stableConversation — only need 0↔1+ transition for landing page check,
  not exact count on every message addition
- Add defensive comment at chatContext destructuring point in
  useMessageActions explaining why isSubmitting must not be destructured
- Remove dead mockUseFileHandling.mockReturnValue from AttachFileMenu tests

* chore: remove dead useFileHandling mock artifacts from AttachFileMenu tests

* fix: resolve eslint warnings for useMemo dependencies

- Extract complex expression (conversation?.messages?.length ?? 0) > 0
  to hasMessages variable for static analysis in ChatFormWrapper
- Add eslint-disable for intentional isSubmitting dep in
  useMemoizedChatContext (forces new chatContext reference on streaming
  start/end so HoverButtons re-renders)
ThomasVuNguyen pushed a commit to ThomasVuNguyen/LibreChat that referenced this pull request Jul 15, 2026
…danny-avila#12454)

* refactor: add TMessageChatContext type for stable context passing

Defines a type for a stable context object that wrapper components
pass to memo'd message components, avoiding direct ChatContext
subscriptions that bypass React.memo during streaming.

* perf: remove ChatContext subscription from useMessageActions

useMessageActions previously called useChatContext() inside memo'd
components (MessageRender, ContentRender), bypassing React.memo
when isSubmitting changed during streaming. Now accepts a stable
chatContext param instead, using a ref for the isSubmitting guard
in regenerateMessage.

Also stabilizes handleScroll in useMessageProcess by using a ref
for isSubmitting instead of including it in useCallback deps.

* perf: pass stable chatContext to memo'd message components

Wrapper components (Message, MessageContent) now create a stable
chatContext object via useMemo with a getter-backed isSubmitting,
and compute effectiveIsSubmitting (false for non-latest messages).

This ensures MessageRender and ContentRender (both React.memo'd)
only re-render for the latest message during streaming, preventing
unnecessary re-renders of all prior messages and their SubRow,
HoverButtons, and SiblingSwitch children.

* perf: add custom memo comparators to prevent message reference re-renders

buildTree creates new message objects on every streaming update for
ALL messages, not just the changed one. This defeats React.memo's
default shallow comparison since the message prop has a new reference
even when the content hasn't changed.

Custom areEqual comparators now compare message by key fields
(messageId, text, error, depth, children length, etc.) instead of
reference equality, preventing unnecessary re-renders of SubRow,
Files, HoverButtons and other children for non-latest messages.

* perf: memoize ChatForm children to prevent streaming re-renders

- Wrap StopButton in React.memo
- Wrap AudioRecorder in React.memo, use ref for isSubmitting in
  onTranscriptionComplete callback to stabilize it
- Remove useChatContext() from FileFormChat (bypassed its memo
  during streaming), accept files/setFiles/setFilesLoading as
  props from ChatForm instead

* perf: stabilize ChatForm child props to prevent cascading re-renders

ChatForm re-renders frequently during streaming (ChatContext changes).
This caused StopButton and AttachFileChat/AttachFileMenu to re-render
despite being memo'd, because their props were new references each time.

- Wrap handleStopGenerating in a ref-based stable callback so StopButton
  always receives the same function reference
- Create stableConversation via useMemo keyed on rendering-relevant
  fields only (conversationId, endpoint, agent_id, etc.), so
  AttachFileChat and FileFormChat don't re-render from unrelated
  conversation metadata updates (e.g., title generation)

* perf: remove ChatContext subscription from AttachFileMenu and FileFormChat

Both components used useFileHandling() which internally calls
useChatContext(), bypassing their React.memo wrappers and causing
re-renders on every streaming chunk.

Switch to useFileHandlingNoChatContext() which accepts file state
as parameters. The state (files, setFiles, setFilesLoading,
conversation) is passed down from ChatForm → AttachFileChat →
AttachFileMenu as props, keeping the memo chain intact.

* fix: update imports and test mocks for useFileHandlingNoChatContext

- Re-export useFileHandlingNoChatContext from hooks barrel
- Import from ~/hooks instead of direct path for test compatibility
- Add useToastContext mock to @librechat/client in AttachFileMenu tests
  since useFileHandlingNoChatContext runs the core hook which needs it
- Add useFileHandlingNoChatContext to ~/hooks test mock

* perf: fix remaining ChatForm streaming re-renders

- Switch AttachFileMenu from useSharePointFileHandling (subscribes to
  ChatContext) to useSharePointFileHandlingNoChatContext with explicit
  file state props
- Memoize ChatForm textarea onFocus/onBlur handlers with useCallback
  to prevent TextareaAutosize re-renders (inline arrow functions and
  .bind() created new references on every ChatForm render)
- Update AttachFileMenu test mocks for new hook variants

* refactor: add displayName to ChatForm for React DevTools

* perf: prevent ChatForm re-renders during streaming via wrapper pattern

ChatForm was re-rendering on every streaming chunk because it subscribed
to useChatContext() internally, and the ChatContext value changed
frequently during streaming.

Extract context subscription into a ChatFormWrapper that:
- Subscribes to useChatContext() (re-renders on every chunk, cheap)
- Stabilizes conversation via selective useMemo
- Stabilizes handleStopGenerating via ref-based callback
- Passes individual stable values as props to ChatForm

ChatForm (memo'd) now receives context values as props instead of
subscribing directly. Since individual values (files, setFiles,
isSubmitting, etc.) are stable references during streaming, ChatForm's
memo prevents re-renders entirely — it only re-renders when isSubmitting
actually toggles (2x per stream: start/end).

* perf: stabilize newConversation prop and memoize CollapseChat

- Wrap newConversation in ref-based stable callback in ChatFormWrapper
  (was the remaining unstable prop causing ChatForm to re-render)
- Wrap CollapseChat in React.memo to prevent re-renders from parent

* perf: memoize useAddedResponse return value

useAddedResponse returned a new object literal on every render,
causing AddedChatContext.Provider to trigger re-renders of all
consumers (including ChatForm) on every streaming chunk. Wrap in
useMemo so the context value stays referentially stable.

* perf: memoize TextareaHeader to prevent re-renders from ChatForm

* perf: address review findings for streaming render optimization

Finding 1: Switch AttachFile.tsx from useFileHandling to
useFileHandlingNoChatContext, closing the optimization hole for
standard (non-agent) chat endpoints.

Finding 2: Replace content reference equality with length comparison
in both memo comparators — safer against buildTree array reconstruction.

Finding 3: Add conversation?.model to stableConversation deps in
ChatFormWrapper so file uploads use the correct model after switches.

Finding 4/14: Fix stableNewConversation to explicitly return the
underlying call's result instead of discarding it via `as` cast.

Finding 5/6: Extract useMemoizedChatContext hook shared by Message.tsx
and MessageContent.tsx — eliminates ~70 lines of duplication and
stabilizes chatContext.conversation via selective useMemo to prevent
post-stream metadata updates from re-rendering all messages.

Finding 8: Use TMessage type for regenerate param instead of
Record<string, unknown>.

Finding 9: Use FileSetter alias in FileFormChat instead of inline type.

Finding 11: Fix pre-existing broken throttle in useMessageProcess —
was creating a new throttle instance per call, providing zero
deduplication. Now retains the instance via useMemo.

Finding 12: Initialize isSubmittingRef with chatContext.isSubmitting
instead of false for consistency.

Finding 13: Add ChatFormWrapper displayName.

* fix: revert content comparison to reference equality in memo comparators

The length-based comparison (content?.length) missed updates within
existing content parts during streaming — text chunks update a part's
content without changing the array length, so the comparator returned
true and skipped re-renders for the latest message.

Reference equality (===) is correct here: buildTree preserves content
array references for unchanged messages via shallow spread, while
React Query gives the latest message a new reference when its content
updates during streaming.

* fix: cancel throttled handleScroll on unmount and remove unused import

* fix: use chatContext getter directly in regenerateMessage callback

The local isSubmittingRef was stale for non-latest messages (which
don't re-render during streaming by design). chatContext.isSubmitting
is a getter backed by the wrapper's ref, so reading it at call-time
always returns the current value regardless of whether the component
has re-rendered.

* fix: remove unused useCallback import from useMemoizedChatContext

* fix: pass global isSubmitting to HoverButtons for action gating

HoverButtons uses isSubmitting via useGenerationsByLatest to disable
regenerate and hide edit buttons during streaming. Passing the
effective value (false for non-latest messages) re-enabled those
actions mid-stream, risking overlapping edits/regenerations.

Use chatContext.isSubmitting (getter, always returns current value)
for HoverButtons while keeping the effective value for rendering-only
UI (cursor, placeholder, streaming indicator).

* fix: address second review — stale HoverButtons, messages dep, cleanup

- Add isSubmitting to chatContext useMemo deps in useMemoizedChatContext
  so HoverButtons correctly updates when streaming starts/ends (2 extra
  re-renders per session, belt-and-suspenders for post-stream state)
- Change conversation?.messages?.length dep to boolean in ChatFormWrapper
  stableConversation — only need 0↔1+ transition for landing page check,
  not exact count on every message addition
- Add defensive comment at chatContext destructuring point in
  useMessageActions explaining why isSubmitting must not be destructured
- Remove dead mockUseFileHandling.mockReturnValue from AttachFileMenu tests

* chore: remove dead useFileHandling mock artifacts from AttachFileMenu tests

* fix: resolve eslint warnings for useMemo dependencies

- Extract complex expression (conversation?.messages?.length ?? 0) > 0
  to hasMessages variable for static analysis in ChatFormWrapper
- Add eslint-disable for intentional isSubmitting dep in
  useMemoizedChatContext (forces new chatContext reference on streaming
  start/end so HoverButtons re-renders)
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.

2 participants