Skip to content

⚡️(front) throttle chat stream re-renders - #628

Open
providenz wants to merge 1 commit into
mainfrom
providenz/fix-max-depth
Open

⚡️(front) throttle chat stream re-renders#628
providenz wants to merge 1 commit into
mainfrom
providenz/fix-max-depth

Conversation

@providenz

@providenz providenz commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Purpose

Chat responses were re-rendering the entire conversation view once per streamed chunk, roughly a hundred times per second on a long answer. This burned a large amount of main thread work on every message, and is the suspected cause of the maximum update depth exceeded errors reported from production.

Proposal

  • Throttle streaming updates so the conversation view refreshes at a steady rate rather than once per chunk. Measured on a long generated answer, renders dropped by roughly five times and the burst now ends with the stream.
  • Add a temporary render diagnostic that counts renders per second for the permanently mounted components and records which values changed between renders. On a runaway loop it attaches that tally to the error report, because the React error itself never names the component responsible.
  • Keep diagnostic console output behind a local storage flag so ordinary users see nothing, and send only component names and counts to error reporting, never conversation content.
  • Remove the diagnostic once the production errors are confirmed gone.

Summary by CodeRabbit

  • Bug Fixes

    • Chat stream updates are now throttled to reduce excessive re-renders and improve responsiveness during streaming.
    • Improved detection and reporting of React render-loop errors, helping identify potential causes of update-depth failures.
  • Diagnostics

    • Added enhanced runtime diagnostics for chat, authentication, configuration, layout, and navigation rendering behavior.
    • Added targeted logging for chat stream status and render activity to support troubleshooting.

Signed-off-by: Laurent Paoletti <lp@providenz.fr>
@sonarqubecloud

Copy link
Copy Markdown

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The frontend adds render-loop tracking and React update-depth capture through Sentry, instruments several conversation components, and throttles AI chat stream updates to 50 milliseconds. The changelog documents the stream throttling change.

Changes

Frontend diagnostics and stream updates

Layer / File(s) Summary
Render-loop diagnostic utility
src/frontend/apps/conversations/src/utils/debugRenderLoop.ts, src/frontend/apps/conversations/src/utils/index.ts
Adds render counters, changed-key detection, sampled logging, React loop detection, Sentry reporting, and public utility exports.
Render instrumentation wiring
src/frontend/apps/conversations/src/pages/_app.tsx, src/frontend/apps/conversations/src/core/config/ConfigProvider.tsx, src/frontend/apps/conversations/src/features/auth/components/Auth.tsx, src/frontend/apps/conversations/src/layouts/MainLayout.tsx, src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanel.tsx, src/frontend/apps/conversations/src/features/chat/components/{Chat,InputChat}.tsx
Installs loop capture and records render-time state across configuration, authentication, layout, panel, chat, and input components.
Chat stream throttling and status diagnostics
src/frontend/apps/conversations/src/features/chat/api/useChat.tsx, CHANGELOG.md
Throttles chat stream updates to 50 ms and samples chat status changes; documents the frontend throttling behavior.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Suggested reviewers: elvoisin

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main user-facing change: throttling chat stream re-renders on the frontend.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch providenz/fix-max-depth

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.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🧹 Nitpick comments (3)
src/frontend/apps/conversations/src/utils/debugRenderLoop.ts (2)

146-156: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Sentry context is never cleared, so it can taint unrelated later events.

Sentry.setContext('render_loop', report) (and render_loop_error in installRenderLoopCapture) write onto the current/global scope with no expiry or reset. Once a component crosses WARN_THRESHOLD, every subsequent Sentry event captured for the rest of the session/scope — including ones completely unrelated to a render loop — will carry this stale render_loop context, which can mislead whoever triages those later reports.

Suggested fix: clear the context after a short window
   const report = { component: name, renders, elapsedMs, busiest: snapshot() };
   Sentry.setContext('render_loop', report);
   Sentry.addBreadcrumb({
     category: 'render-loop',
     level: 'warning',
     message: `${name} rendered ${renders}x in ${elapsedMs}ms`,
     data: report,
   });
+  // Avoid tainting unrelated events reported long after the loop resolved.
+  if (typeof window !== 'undefined') {
+    window.setTimeout(() => Sentry.setContext('render_loop', null), 5000);
+  }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/apps/conversations/src/utils/debugRenderLoop.ts` around lines
146 - 156, Update the render-loop reporting flow around
Sentry.setContext('render_loop', report) and the render_loop_error context in
installRenderLoopCapture so each context is cleared after a short bounded
window, preventing it from attaching to unrelated later events. Preserve the
existing report and breadcrumb payloads while ensuring the cleanup applies to
both context keys.

55-66: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Synchronous localStorage reads on the render hot path.

isConsoleEnabled()/isVerbose() call localStorage.getItem and are invoked up to 3x per trackRender (lines 108, 118, 139) plus once per debugSample call — on every render of the instrumented components, including during 20x/s stream updates. localStorage access isn't free; doing it synchronously on this exact hot path works against the PR's stated goal of reducing main-thread work during streaming.

Consider caching the debug level once (e.g., read at module init, refresh on a storage event or a coarse interval) instead of re-reading it on every render/call.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/apps/conversations/src/utils/debugRenderLoop.ts` around lines 55
- 66, Cache the value read by debugLevel instead of calling localStorage.getItem
on every isConsoleEnabled, isVerbose, trackRender, and debugSample invocation.
Initialize the cache once and refresh it only through a storage-event listener
or another coarse-grained mechanism, while preserving the current null behavior
when localStorage is unavailable.
src/frontend/apps/conversations/src/features/chat/components/InputChat.tsx (1)

461-495: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Some tracked props are known to change identity every parent render, diluting the diff signal.

handleSubmit, onStop, and onToggleWebSearch map to handleSubmitWrapper, handleStop, and toggleWebSearch in Chat.tsx, which are plain inline functions re-created on every Chat render (not useCallback-wrapped); onModelSelect (handleModelSelect) is similarly unmemoized. Because their identity always differs between renders, they'll routinely appear in this component's changedKeys output even when the real cause of a re-render lies elsewhere, burying the signal this tool exists to surface.

Since fixing the parent's memoization is out of scope here, consider dropping these known-unstable function props from the tracked context (or noting them as expected-noisy) so the diff output stays focused on values that actually indicate InputChat's own state driving a loop.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/apps/conversations/src/features/chat/components/InputChat.tsx`
around lines 461 - 495, Update the diagnostic payload passed to trackRender in
InputChat to omit the known-unstable callback props handleSubmit, onStop,
onToggleWebSearch, and onModelSelect. Keep the remaining tracked values
unchanged so changedKeys focuses on meaningful render differences.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/frontend/apps/conversations/src/utils/debugRenderLoop.ts`:
- Around line 91-133: Update trackRender so render-time calls do not mutate
counters or record Sentry diagnostic state during React Strict Mode double
invocation. Move counting and changed-key processing into an effect-based path,
or gate all counter increments and related state updates behind the opt-in debug
flag before recording diagnostics; preserve verbose logging only when debugging
is enabled.

---

Nitpick comments:
In `@src/frontend/apps/conversations/src/features/chat/components/InputChat.tsx`:
- Around line 461-495: Update the diagnostic payload passed to trackRender in
InputChat to omit the known-unstable callback props handleSubmit, onStop,
onToggleWebSearch, and onModelSelect. Keep the remaining tracked values
unchanged so changedKeys focuses on meaningful render differences.

In `@src/frontend/apps/conversations/src/utils/debugRenderLoop.ts`:
- Around line 146-156: Update the render-loop reporting flow around
Sentry.setContext('render_loop', report) and the render_loop_error context in
installRenderLoopCapture so each context is cleared after a short bounded
window, preventing it from attaching to unrelated later events. Preserve the
existing report and breadcrumb payloads while ensuring the cleanup applies to
both context keys.
- Around line 55-66: Cache the value read by debugLevel instead of calling
localStorage.getItem on every isConsoleEnabled, isVerbose, trackRender, and
debugSample invocation. Initialize the cache once and refresh it only through a
storage-event listener or another coarse-grained mechanism, while preserving the
current null behavior when localStorage is unavailable.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 53f396e5-82c4-43e3-80ab-e46f458c70e2

📥 Commits

Reviewing files that changed from the base of the PR and between d9fd8f1 and 9ff80b7.

📒 Files selected for processing (11)
  • CHANGELOG.md
  • src/frontend/apps/conversations/src/core/config/ConfigProvider.tsx
  • src/frontend/apps/conversations/src/features/auth/components/Auth.tsx
  • src/frontend/apps/conversations/src/features/chat/api/useChat.tsx
  • src/frontend/apps/conversations/src/features/chat/components/Chat.tsx
  • src/frontend/apps/conversations/src/features/chat/components/InputChat.tsx
  • src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanel.tsx
  • src/frontend/apps/conversations/src/layouts/MainLayout.tsx
  • src/frontend/apps/conversations/src/pages/_app.tsx
  • src/frontend/apps/conversations/src/utils/debugRenderLoop.ts
  • src/frontend/apps/conversations/src/utils/index.ts

Comment on lines +91 to +133
export const trackRender = (
name: string,
context?: Record<string, unknown>,
) => {
const now = Date.now();
let counter = counters.get(name);

if (!counter || now - counter.windowStart > WINDOW_MS) {
counter = { count: 0, windowStart: now, warned: false, diffLogs: 0 };
counters.set(name, counter);
}

counter.count += 1;
const previousValues = counter.previousValues;
counter.previousValues = context;
counter.lastContext = context;

if (isVerbose()) {
console.log(`[render-loop] ${name} #${counter.count}`, context ?? '');
}

// Computed even when the console is silent: the key names ride along to
// Sentry, and they are what identifies the culprit there.
if (context && previousValues && counter.count >= DIFF_THRESHOLD) {
const changed = changedKeys(previousValues, context);
counter.lastChangedKeys = changed;

if (isConsoleEnabled() && counter.diffLogs < DIFF_LOGS_PER_WINDOW) {
counter.diffLogs += 1;
console.log(
`[render-loop] ${name} #${counter.count} changed: ${changed.join(', ') || '(nothing of its own)'}`,
changed.reduce<Record<string, unknown>>((acc, key) => {
acc[key] = { from: previousValues[key], to: context[key] };
return acc;
}, {}),
);
}
}

if (counter.count < WARN_THRESHOLD || counter.warned) {
return;
}
counter.warned = true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd -HI '^next\.config\.(js|mjs|ts|cjs)$' --exec cat {}

Repository: suitenumerique/conversations

Length of output: 1536


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== next config files =="
fd -HI '^next\.config\.(js|mjs|ts|cjs)$'

echo
echo "== debugRenderLoop references and definitions =="
fd -HI '^debugRenderLoop\.ts$'
xargs -r ast-grep outline --view expanded
rg -n "reactStrictMode|trackRender|ReactStrictMode|setNextRuntime" .github .next node_modules src -g '!node_modules' || true

echo
echo "== likely usage sites =="
rg -n "trackRender\(" src || true

echo
echo "== package versions =="
for f in package.json pnpm-lock.yaml package-lock.json yarn.lock bun.lockb bun.lock; do
  [ -e "$f" ] && echo "--- $f ---"
  if [ "$f" = "package.json" ]; then
    node -e "const p=require('package.json'); console.log(JSON.stringify({dependencies:p.dependencies,devDependencies:p.devDependencies,scripts:p.scripts}, null, 2))"
  else
    sed -n '1,120p' "$f"
  fi
done

Repository: suitenumerique/conversations

Length of output: 518


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== next config files =="
fd -HI '^next\.config\.(js|mjs|ts|cjs)$' --exec sh -c 'echo "--- $1 ---"; cat "$1"' sh {}

echo
echo "== debugRenderLoop references and definition =="
fd -HI '^debugRenderLoop\.ts$' --exec sh -c 'echo "--- $1 ---"; wc -l "$1"; ast-grep --view compact outline "$1"; ast-grep --view expanded outline "$1"' sh {}

echo
echo "== search strict mode and trackRender usage =="
rg -n "reactStrictMode|ReactStrictMode|setNextRuntime|trackRender\(" --glob '!node_modules/**' --glob '!*.lock' . || true

echo
echo "== package versions snippets =="
for f in src/frontend/apps/conversations/package.json package.json pnpm-lock.yaml package-lock.json yarn.lock bun.lock; do
  [ -e "$f" ] || continue
  echo "--- $f ---"
  if [ "$f" = "src/frontend/apps/conversations/package.json" ] || [ "$f" = "package.json" ]; then
    node -e "const fs=require('fs'); const p=JSON.parse(fs.readFileSync(process.argv[1],'utf8')); console.log(JSON.stringify({dependencies:p.dependencies,devDependencies:p.devDependencies,scripts:p.scripts}, null, 2))" "$f"
  else
    sed -n '1,180p' "$f"
  fi
done

Repository: suitenumerique/conversations

Length of output: 2100


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== trackRender definition and usage =="
sed -n '1,170p' src/frontend/apps/conversations/src/utils/debugRenderLoop.ts
echo "--- trackRender call sites ---"
rg -n "trackRender\(" src/frontend/apps/conversations/src --glob '!node_modules/**' || true

echo
echo "== strict mode config search =="
rg -n "reactStrictMode|ReactStrictMode|setNextRuntime" --glob '!node_modules/**' --glob '!*.lock' . || true

echo
echo "== package versions snippets =="
for f in src/frontend/apps/conversations/package.json package.json pnpm-lock.yaml package-lock.json yarn.lock bun.lock; do
  [ -e "$f" ] || continue
  echo "--- $f ---"
  if [ "$f" = "src/frontend/apps/conversations/package.json" ] || [ "$f" = "package.json" ]; then
    node -e "const fs=require('fs'); const p=JSON.parse(fs.readFileSync(process.argv[1],'utf8')); console.log(JSON.stringify({dependencies:p.dependencies,devDependencies:p.devDependencies,scripts:p.scripts}, null, 2))" "$f"
  else
    sed -n '1,180p' "$f"
  fi
done

echo
echo "== Next.js documentation availability and strict mode config reference =="
node - <<'JS'
try {
  const fs = require('fs');
  const path = require('path');
  const nextPkgPath = path.join(process.cwd(), 'node_modules/next/package.json');
  console.log(fs.existsSync(nextPkgPath) ? 'next module present' : 'next module absent');
} catch (e) {
  console.log('next module check unavailable:', e.message);
}
JS

Repository: suitenumerique/conversations

Length of output: 10831


Avoid mutating counters from render bodies.

trackRender is called from component renders and increments module-level state, which React double-invokes during development under reactStrictMode. That can double counts and change key diffs, making this diagnostic flag loop activity even when the real app behavior is not looping. Move the counting/diff check to an effect or gate incrementing under the opt-in debug flag before recording Sentry state.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/apps/conversations/src/utils/debugRenderLoop.ts` around lines 91
- 133, Update trackRender so render-time calls do not mutate counters or record
Sentry diagnostic state during React Strict Mode double invocation. Move
counting and changed-key processing into an effect-based path, or gate all
counter increments and related state updates behind the opt-in debug flag before
recording diagnostics; preserve verbose logging only when debugging is enabled.

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