Skip to content

fix: unconditional hook calls in ChatProfiles and FeedbackButtons#2912

Open
EyalAmitay wants to merge 3 commits into
Chainlit:mainfrom
EyalAmitay:fix/react-300-hooks-order
Open

fix: unconditional hook calls in ChatProfiles and FeedbackButtons#2912
EyalAmitay wants to merge 3 commits into
Chainlit:mainfrom
EyalAmitay:fix/react-300-hooks-order

Conversation

@EyalAmitay
Copy link
Copy Markdown
Contributor

@EyalAmitay EyalAmitay commented Apr 26, 2026

Problem

ChatProfiles and FeedbackButtons both violate React's Rules of Hooks by calling useEffect / useCallback hooks after a conditional return null. When the guarding condition changes between renders, the hook count differs and React throws invariant 300 ("Rendered fewer hooks than expected") or 310 ("Rendered more hooks than expected").

image

ChatProfiles (lines 44–66)

The copilot header renders <ChatProfiles /> whenever config?.chatProfiles?.length >= 1. Inside ChatProfiles, the early return fires when length <= 1 — a different threshold — leaving 2 useEffect hooks unreachable:

// 7 hooks called unconditionally …
const [openDialog, setOpenDialog] = useState(false);

if (!config?.chatProfiles?.length || config.chatProfiles.length <= 1) {
  return null; // ← hooks 8 and 9 below are skipped on this render path
}

useEffect(() => {  }, [chatProfile, config.chatProfiles, setChatProfile]); // hook 8
useEffect(() => {  }, [chatProfile, config.chatProfiles, setChatProfile]); // hook 9

How it fires in practice: useConfig clears config to undefined and refetches whenever chatProfile changes. When SWR has a cached response for the new URL it serves it synchronously, so React batches setConfig(undefined) + setConfig(newData) into a single render — config transitions directly from {chatProfiles:[A,B]} to {chatProfiles:[A]} without passing through undefined. The parent header sees hasChatProfiles = !!(1) = true and keeps ChatProfiles mounted; the child hits length <= 1 and takes the early return after 7 hooks; React sees 9 hooks on render N and 7 on render N+1 → invariant 300. When config does pass through undefined, the parent unmounts ChatProfiles first and the fiber is destroyed before React can detect the mismatch — no error fires. The production flakiness reflects which code path the config update takes, not timing.

FeedbackButtons (lines 50–96)

Same pattern: 8 useState/useRecoilValue hooks before if (!showFeedbackButtons) return null, followed by 2 useCallback hooks. showFeedbackButtons derives from !!config?.dataPersistence, so the same config-reload cycle can flip it.

Evidence

~836 occurrences over 30 days across multiple production deployments, single error fingerprint v11.04854025396C892A97427E50E80C58F4, all from */copilot/index.js:

Error: Minified React error #300
  at $be   ← React throwInvariantViolation (renderWithHooks)
  at Zbe   ← renderWithHooks
  at kFe   ← updateFunctionComponent
  at SFe   ← beginWork
Caught by: ErrorBoundary.componentDidCatch
ComponentStack: in GSr (ChatProfiles)

Fix

Hoist all hook calls above the conditional early return; gate the side-effect logic on the same boolean as the guard.

Testing

Two regression tests added in frontend/tests/:

  • ChatProfiles.spec.tsx — mounts the component with 2 profiles, then re-renders with config = undefined (mirroring useConfig's setConfig(undefined) on profile change), then re-renders with config restored. Asserts no "Rendered fewer/more hooks" error is logged. Fails on the unfixed code, passes after the fix.

  • FeedbackButtons.spec.tsx — same pattern, toggling showFeedbackButtons between true and false on the same fiber.

  • Before fix: both ChatProfiles.spec.tsx and FeedbackButtons.spec.tsx fail with "Rendered fewer hooks than expected. This may be caused by an accidental early return statement." - exactly React's invariant 300
    message

  • After fix: all 34 tests pass

pnpm --filter @chainlit/app type-check and pnpm lint both pass.

Both components violated React's Rules of Hooks by placing useEffect /
useCallback calls after a conditional return null. When the guarding
condition flips between renders (e.g. config.chatProfiles count changes
as useConfig clears and refetches on chatProfile selection), React detects
a different hook count and throws invariant 300/310.

Fix: hoist all hook calls unconditionally above the guard, gate their
side-effect logic on the same condition used by the early return.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@dosubot dosubot Bot added size:M This PR changes 30-99 lines, ignoring generated files. bug Something isn't working frontend Pertains to the frontend. labels Apr 26, 2026
@EyalAmitay EyalAmitay marked this pull request as draft April 26, 2026 13:29
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

No issues found across 2 files

Copy link
Copy Markdown

@jschwarz89 jschwarz89 left a comment

Choose a reason for hiding this comment

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

Looks good to me, solves a real crash for us!

…les and FeedbackButtons

Mounts each component then re-renders with the guarding condition flipped
(config -> undefined -> config; showFeedbackButtons true -> false -> true)
on the same fiber, asserting React doesn't log/throw a hooks-count
mismatch. Verified by reverting f3a820d locally — both tests fail with
"Rendered fewer hooks than expected" — and pass with the fix in place.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@EyalAmitay EyalAmitay changed the title fix: move hooks above early returns in ChatProfiles and FeedbackButtons fix: unconditional hook calls in ChatProfiles and FeedbackButtons Apr 27, 2026
@EyalAmitay EyalAmitay marked this pull request as ready for review April 27, 2026 07:18
@dosubot dosubot Bot added the unit-tests Has unit tests. label Apr 27, 2026
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

1 issue found across 4 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="frontend/tests/FeedbackButtons.spec.tsx">

<violation number="1" location="frontend/tests/FeedbackButtons.spec.tsx:86">
P2: The hook-order matcher misses React’s standard "Rendered fewer hooks than expected" error text, so this regression test can pass even when the hook-order bug is present.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread frontend/tests/FeedbackButtons.spec.tsx Outdated
… messages

React invariant 300 throws 'Rendered fewer hooks than expected' (not
'…than during the previous render'), so the previous pattern missed it.
Split into two separate patterns to cover both invariant 300 and 310.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
@EyalAmitay EyalAmitay force-pushed the fix/react-300-hooks-order branch from ca58f2e to 11161d6 Compare April 27, 2026 10:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working frontend Pertains to the frontend. size:M This PR changes 30-99 lines, ignoring generated files. unit-tests Has unit tests.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants