Skip to content

fix(chat): stop the composer-footer render loop (#5162) - #5274

Merged
M3gA-Mind merged 2 commits into
tinyhumansai:mainfrom
M3gA-Mind:fix/5162-render-loop
Jul 31, 2026
Merged

fix(chat): stop the composer-footer render loop (#5162)#5274
M3gA-Mind merged 2 commits into
tinyhumansai:mainfrom
M3gA-Mind:fix/5162-render-loop

Conversation

@M3gA-Mind

@M3gA-Mind M3gA-Mind commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Fix the infinite render loop behind Maximum update depth exceeded (Sentry TAURI-REACT-2G, ~12 events / ~5 users across 3 shortIds).
  • Root cause: ChatPageScaffold's composer-footer ResizeObserver effect depended on the footer React node, which is inline JSX at every call site and therefore has a new object identity on every render.
  • Subscribe on footer presence instead of identity, and drop measurements that match the committed height.
  • Harden the same shape in the main chat (Conversations) so a sub-pixel rounding oscillation cannot feed the same cascade.
  • Replace two per-render [] allocations in the chat hot path with the stable EMPTY_* constants the surrounding code already established (also clears both react-hooks/exhaustive-deps warnings there).

Problem

ChatPageScaffold (app/src/components/orchestration/AgentChatPanel.tsx) measures its floating composer footer with a ResizeObserver and feeds the height into the scroll region's bottom padding. The effect's dependency was the footer node itself:

}, [footer]);   // footer is inline JSX at every call site

Because both call sites pass inline JSX, footer takes a fresh object identity on every render. Three effects compounded:

  1. The effect re-ran on every render, tearing down and rebuilding the observer each pass.
  2. ResizeObserver.observe() delivers an immediate initial observation, so every render scheduled another setFooterHeight.
  3. setFooterHeight was unconditional, so any measurement disagreeing with committed state re-rendered → new footer identity → back to step 1.

That cascade runs until React trips its nested-update limit and throws Maximum update depth exceeded.

Why Sentry blamed ChatComposer.onChange: the composer's auto-growing textarea lives inside that footer, so each keystroke changed the measured height and pumped the cycle. The composer was the trigger, not the defect — which is why the previous mitigations tagged TAURI-REACT-2G in ChatComposer / Conversations / ThreadGoalChip never closed the issue.

Solution

AgentChatPanel.tsx (root cause). Key the subscription on footer presence (hasFooter = Boolean(footer)) rather than the node. The observer already reports every size change, so it only needs re-subscribing when the footer element mounts or unmounts — not when its children re-render. A local applyHeight helper drops measurements equal to the committed height, so a settled layout can never schedule a re-render and therefore can never feed a cascade.

Conversations.tsx (same shape, hardening). The main chat's composer-footer observer now skips no-op updates. Its deps were already stable, so this was not looping, but it watches the footer that contains the composer while feeding the message list's padding — the exact coupling that lets sub-pixel rounding oscillate.

Conversations.tsx + ChatThreadView.tsx (render churn). selectedThreadToolTimeline / selectedThreadProcessing allocated a fresh [] per render, giving them a new identity every pass and invalidating the backgroundProcesses useMemo every time. Both files already define EMPTY_* constants for precisely this reason; these two sites were missed.

Behavior is preserved throughout — the observer still reports every real size change.

Submission Checklist

If a section does not apply to this change, mark the item as N/A with a one-line reason. Do not delete items.

  • Tests added or updated (happy path + at least one failure / edge case) per Testing Strategy
  • Diff coverage ≥ 80% — changed lines (Vitest + cargo-llvm-cov merged via diff-cover) meet the gate enforced by .github/workflows/ci-lite.yml. Run pnpm test:coverage and pnpm test:rust locally; PRs below 80% on changed lines will not merge.
  • N/A: behaviour-only change — Coverage matrix updated — added/removed/renamed feature rows in docs/TEST-COVERAGE-MATRIX.md reflect this change (no feature rows added/removed/renamed)
  • N/A — All affected feature IDs from the matrix are listed in the PR description under ## Related (no matrix rows change)
  • No new external network dependencies introduced (mock backend used per Testing Strategy)
  • N/A: no release-cut surface touched — Manual smoke checklist updated if this touches release-cut surfaces (docs/RELEASE-MANUAL-SMOKE.md)
  • Linked issue closed via Closes #NNN in the ## Related section

Regression test

app/src/components/orchestration/__tests__/AgentChatPanel.test.tsx adds a composer footer measurement block that stubs ResizeObserver (modelling the real initial observation on observe()) and reports a real offsetHeight for the footer wrapper only.

  • does not rebuild the footer ResizeObserver on every render while typing — the failure-path test. Verified failing before / passing after: with only the dependency array reverted to [footer] (test hook kept in place), it reports 4 footer observers instead of 1 — one per keystroke render.
  • still reserves the measured footer height on the scroll region — the happy path, guarding that the changed subscription key didn't break the layout feature: a size change through the observer still updates paddingBottom.

useStickToBottom also builds a ResizeObserver, so the assertions select ours by the element it observes.

Impact

  • Platform: desktop (Tauri/CEF) React renderer only. No Rust, no core, no RPC, no schema change.
  • Performance: strictly positive — removes a per-render observer teardown/rebuild on the orchestration chat and two per-render array allocations plus a wasted memo recompute on the main chat's hot path.
  • Security / migration / compatibility: none.
  • User-visible: the crash stops; layout behaviour is unchanged.

Related

  • Closes: Maximum update depth exceeded — infinite render loop across multiple components #5162
  • Follow-up PR(s)/TODOs: the render-loop diagnostic guard in app/src/components/chat/ChatComposer.tsx (lines ~112-129) is a leftover mitigation for this issue — it mutates a ref and calls setTimeout during render (an impure render side effect) and console.warns in production. Left in place to keep this diff focused; worth removing now that the root cause is fixed.

AI Authored PR Metadata (required for Codex/Linear PRs)

Keep this section for AI-authored PRs. For human-only PRs, mark each field N/A.

Linear Issue

  • Key: N/A
  • URL: N/A

Commit & Branch

  • Branch: N/A
  • Commit SHA: N/A

Validation Run

  • pnpm --filter openhuman-app format:check — Prettier clean on all four changed files
  • pnpm typecheck — clean
  • Focused tests: vitest related over the three changed source files — 18 files / 287 tests passed; full frontend suite 768 files / 8971 tests passed, 0 failed
  • N/A: no Rust changed — Rust fmt/check (if changed)
  • N/A: no Tauri shell changed — Tauri fmt/check (if changed)

Validation Blocked

  • command: N/A
  • error: N/A
  • impact: N/A

Behavior Changes

  • Intended behavior change: none beyond removing the crash — the footer observer still reports every real size change and the reserved padding is identical.
  • User-visible effect: the chat surfaces no longer die with Maximum update depth exceeded while typing.

Parity Contract

  • Legacy behavior preserved: yes. The footer height still drives the scroll region's paddingBottom; only the subscription key (presence vs node identity) and the no-op-update guard changed.
  • Guard/fallback/dispatch parity checks: the typeof ResizeObserver === 'undefined' one-shot fallback is retained; the !el early return now also covers !hasFooter, and both still reset the height to 0.

Duplicate / Superseded PR Handling

  • Duplicate PR(s): none
  • Canonical PR: this PR
  • Resolution (closed/superseded/updated): N/A

Summary by CodeRabbit

  • Bug Fixes

    • Prevented chat composer layout updates from entering a repeated update loop.
    • Improved footer height tracking so scrolling reserves the correct space and updates reliably when the footer resizes.
    • Reduced unnecessary updates when no conversation thread is selected or when measured dimensions remain unchanged.
  • Tests

    • Added regression coverage for footer resizing, scrolling space, and stable observer behavior.

`ChatPageScaffold` measured its floating composer footer with a
ResizeObserver whose effect depended on the `footer` node itself. Every
call site passes inline JSX, so `footer` took a fresh object identity on
each render: the effect re-ran every pass, tearing down and rebuilding
the observer and re-measuring. Because `ResizeObserver.observe` delivers
an immediate initial observation, each render scheduled another
`setFooterHeight`, and any measurement disagreeing with the committed
height re-rendered, re-ran the effect, and cascaded until React aborted
with "Maximum update depth exceeded".

Typing was the easiest trigger — the composer's auto-growing textarea
lives inside that footer, so every keystroke changed the measured height,
which is why Sentry blamed `ChatComposer`'s `onChange`.

Subscribe on footer *presence* instead: the observer already reports
every size change, so it only needs re-subscribing when the footer
element mounts or unmounts. Measurements matching the committed height
are dropped so a settled layout can never schedule a re-render.

Also harden the main chat, which has the same shape:

- `Conversations`' composer-footer observer now skips no-op updates, so a
  sub-pixel rounding oscillation can't feed the same cascade.
- `selectedThreadToolTimeline` / `selectedThreadProcessing` in both
  `Conversations` and `ChatThreadView` allocated a fresh `[]` per render,
  invalidating the `backgroundProcesses` memo every pass. Use the stable
  empty constants the surrounding code already established, which also
  clears both `react-hooks/exhaustive-deps` warnings there.

Regression test asserts the footer observer is built once across
re-renders while typing (it was one per keystroke before) and that the
scroll region still reserves the measured height.
@M3gA-Mind
M3gA-Mind requested a review from a team July 30, 2026 13:01

@greptile-apps greptile-apps 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.

M3gA-Mind has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 20 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e707495b-31be-4b4a-ac57-4a029f5db00b

📥 Commits

Reviewing files that changed from the base of the PR and between 6a24aeb and 3b4c6c5.

📒 Files selected for processing (2)
  • app/src/features/conversations/components/ChatThreadView.memoIdentity.test.tsx
  • app/src/pages/__tests__/Conversations.render.test.tsx
📝 Walkthrough

Walkthrough

The changes stabilize empty conversation-state references and revise footer height measurement to avoid redundant React updates. Regression tests cover observer reuse, measured padding updates, and simulated footer resizing.

Changes

Chat stability improvements

Layer / File(s) Summary
Stable thread state fallbacks
app/src/features/conversations/Conversations.tsx, app/src/features/conversations/components/ChatThreadView.tsx
Tool timeline and processing transcript selectors now reuse stable empty arrays when no selected-thread data exists.
Footer measurement stabilization
app/src/components/orchestration/AgentChatPanel.tsx, app/src/features/conversations/Conversations.tsx, app/src/components/orchestration/__tests__/AgentChatPanel.test.tsx
Footer observers depend on footer presence, skip unchanged measurements, expose a footer test id, and verify padding and observer reuse.
Estimated code review effort: 3 (Moderate) ~20 minutes

Possibly related PRs

Suggested labels: bug

Suggested reviewers: al629176, senamakel

Poem

A rabbit watched the footer grow,
Then stopped the loops that made it flow.
Stable arrays sat still and neat,
While padding matched the height complete.
“No depth loops!” the bunny cheered.

🚥 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 fix: stopping the chat composer-footer render loop.
Linked Issues check ✅ Passed The changes target the reported Maximum update depth exceeded loop and its composer-footer trigger, matching issue #5162.
Out of Scope Changes check ✅ Passed The stable empty-array constants and related guards are part of the render-loop fix and memoization cleanup, not unrelated work.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

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

@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

🤖 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 `@app/src/features/conversations/Conversations.tsx`:
- Around line 166-172: Add regression tests for stable empty fallback identities
in app/src/features/conversations/Conversations.tsx:166-172 and
app/src/features/conversations/components/ChatThreadView.tsx:106-111. Rerender
Conversations without a selected thread and ChatThreadView with threadId={null},
then verify the respective background-process/processing derivation is not
recomputed due to a new fallback identity; cover the related usage sites at
Conversations.tsx:1605-1610 and ChatThreadView.tsx:262-267.
- Around line 1757-1763: Add a regression test covering the composer footer
measurement observer in Conversations: after an initial measurement updates the
height, trigger a second identical measurement and verify the component does not
perform another state-driven render update. Keep the existing changed-height
padding assertion intact and target the setComposerFooterHeight no-op path.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: cf0d6de6-7a3e-4576-be75-e8a390a6c59e

📥 Commits

Reviewing files that changed from the base of the PR and between bb83836 and 6a24aeb.

📒 Files selected for processing (4)
  • app/src/components/orchestration/AgentChatPanel.tsx
  • app/src/components/orchestration/__tests__/AgentChatPanel.test.tsx
  • app/src/features/conversations/Conversations.tsx
  • app/src/features/conversations/components/ChatThreadView.tsx

Comment thread app/src/features/conversations/Conversations.tsx
Comment thread app/src/features/conversations/Conversations.tsx
…nce (tinyhumansai#5162)

Two coverage gaps CodeRabbit flagged on the render-loop fix.

`ChatThreadView.memoIdentity.test.tsx` (new) pins the stable-empty-fallback
contract: it wraps `selectBackgroundProcesses` (delegating to the real
implementation) and asserts the `backgroundProcesses` memo is not re-derived
across re-renders, both with no thread selected and for a thread with no
timeline yet. Verified failing against the previous per-render `[]` fallback —
the selector ran 3x and 2x respectively instead of once.

`Conversations.render.test.tsx` gains a convergence test for the composer-footer
ResizeObserver: a settled layout re-reporting the same height stops costing
renders entirely, while a genuine resize still updates the padding. Render
passes are counted through `useUsageState`, which `Conversations` calls exactly
once per render.

That second test deliberately does NOT claim to discriminate the
`prev === next ? prev : next` guard in `measure()`. React's own Object.is
bail-out already absorbs an identical setState, so render counts are identical
with and without that guard — measured 8 → 9, 9, 9 either way. Asserting
otherwise would be a test that passes for the wrong reason. The guard stays as
defensive, self-documenting code; what the test pins is the property that
actually matters: repeat notifications converge and never walk the padding.

@greptile-apps greptile-apps 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.

M3gA-Mind has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

@M3gA-Mind
M3gA-Mind merged commit c096ecb into tinyhumansai:main Jul 31, 2026
20 checks passed
@github-project-automation github-project-automation Bot moved this from Todo to Done in Team Openhuman Jul 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

Maximum update depth exceeded — infinite render loop across multiple components

1 participant