Skip to content

fix(frontend): Ask spinner never stops under React Strict Mode - #1235

Open
zivkidd1 wants to merge 2 commits into
lfnovo:mainfrom
zivkidd1:fix/ask-mounted-ref-strict-mode
Open

fix(frontend): Ask spinner never stops under React Strict Mode#1235
zivkidd1 wants to merge 2 commits into
lfnovo:mainfrom
zivkidd1:fix/ask-mounted-ref-strict-mode

Conversation

@zivkidd1

@zivkidd1 zivkidd1 commented Aug 1, 2026

Copy link
Copy Markdown

Problem

In development the Ask panel spins forever and the answer is never rendered — even when the SSE stream completes normally and the backend returns 200.

Root cause

useAsk's mount-tracking effect declares only a cleanup function and never sets the ref back to true:

const mountedRef = useRef(true)

useEffect(() => {
  // no setup body
  return () => {
    mountedRef.current = false
    ...
  }
}, [])

reactStrictMode defaults to true (Next.js 13.5+), so in development React mounts → unmounts → remounts. The first cleanup sets mountedRef.current = false and nothing ever sets it back, so every if (mountedRef.current) guard in the hook is dead code for the rest of the session.

That silently disables all five paths that end the loading state, since each one updates state behind that guard:

isStreaming therefore stays true forever, and because finalAnswer is written by the same setState call, the answer never reaches the UI either.

Impact

  • Development only — production builds don't double-invoke effects, which is likely why this went unreported.
  • It also neutralizes the dangling-connection watchdog from [Bug]: Failure UI render in Ask and Search feature #777: the timer still fires and aborts the request, but its setState sits behind the same guard, so the spinner never clears.
  • Failures are especially confusing: console.error and toast.error are outside the guard, so an error toast appears while the spinner keeps spinning and no error state is rendered.

Reproduce

  1. npm run dev
  2. Open /search, configure Ask models, ask a question
  3. Wait for the stream to finish (the dev server logs POST /api/search/ask 200 in …)
  4. The spinner keeps spinning and no answer appears

Fix

Set mountedRef.current = true on (re)mount, so the guards survive Strict Mode's remount.

Regression test

frontend/src/lib/hooks/use-ask.test.tsx renders useAsk inside StrictMode (so the mount → unmount → remount sequence actually runs) and drives a mocked SSE stream through both terminal paths: a normal final_answer + complete, and an in-band error event.

The existing suite could not catch this because nothing exercised a remount.

The new test fails on the pre-fix hook. With mountedRef.current = true reverted:

 FAIL  src/lib/hooks/use-ask.test.tsx > useAsk under React StrictMode > clears the loading state and exposes the answer when the stream completes
 FAIL  src/lib/hooks/use-ask.test.tsx > useAsk under React StrictMode > surfaces an in-band error event instead of loading forever
AssertionError: expected true to be false // Object.is equality

- Expected
+ Received

- false
+ true

 ❯ src/lib/hooks/use-ask.test.tsx:81:40
     79|     })
     80|
     81|     expect(result.current.isStreaming).toBe(false)
       |                                        ^

 Test Files  1 failed (1)
      Tests  2 failed (2)

Verification

npm run test:

 ✓ src/lib/utils/source-context.test.ts (20 tests) 5ms
 ✓ src/lib/hooks/use-translation.test.ts (2 tests) 20ms
 ✓ src/lib/locales/interpolation.test.ts (5 tests) 11ms
 ✓ src/components/sources/ChatPanel.test.tsx (4 tests) 140ms
 ✓ src/lib/locales/index.test.ts (27 tests) 8975ms

 Test Files  24 passed (24)
      Tests  142 passed (142)
   Duration  10.64s

npx eslint src/lib/hooks/use-ask.ts src/lib/hooks/use-ask.test.tsx — no output (clean)

npx tsc --noEmit — no output (clean)

npm run build — succeeded, all routes compiled.

The mountedRef effect only declared a cleanup function and never set the
ref back to true on mount. React Strict Mode mounts, unmounts and remounts
in development, so the first cleanup left mountedRef.current false forever
and every guard depending on it became dead code.

That silently disabled all five paths that end the loading state:
stopStreaming on final_answer and complete, the normal end-of-stream call,
the error handler, and the idle watchdog added in lfnovo#777. The Ask spinner ran
forever and the answer was never rendered, even when the stream completed
successfully.

@cubic-dev-ai cubic-dev-ai 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.

1 issue found across 1 file

Confidence score: 4/5

  • In frontend/src/lib/hooks/use-ask.ts, the code change looks minimal, but without a regression test for the Strict Mode mount/unmount behavior this bug could silently return in a later refactor and reintroduce duplicate or incorrect ask flows for users—add a focused regression test that reproduces the original Strict Mode scenario and captures the before/after evidence.
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/src/lib/hooks/use-ask.ts">

<violation number="1" location="frontend/src/lib/hooks/use-ask.ts:58">
P3: The Strict Mode fix itself is correct and minimal. Since this is framed as a bug fix and the project convention requires a regression test with real evidence for bug fixes, consider adding a test that exercises the mount → unmount → remount sequence and asserts that the mountedRef guards still allow the end-of-loading paths (stopStreaming on final_answer/complete, idle watchdog, error clear of isStreaming) to run. The current PR only notes that the existing 140-test suite still passes, which cannot catch this dev-only regression.</violation>
</file>

Tip: cubic used a learning from your PR history. Let your coding agent read cubic learnings directly with the cubic MCP.

Re-trigger cubic

// Must be re-set on every mount: React Strict Mode mounts, unmounts and
// remounts in development, and the cleanup below would otherwise leave this
// false forever, making every mountedRef guard dead code.
mountedRef.current = true

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.

P3: The Strict Mode fix itself is correct and minimal. Since this is framed as a bug fix and the project convention requires a regression test with real evidence for bug fixes, consider adding a test that exercises the mount → unmount → remount sequence and asserts that the mountedRef guards still allow the end-of-loading paths (stopStreaming on final_answer/complete, idle watchdog, error clear of isStreaming) to run. The current PR only notes that the existing 140-test suite still passes, which cannot catch this dev-only regression.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At frontend/src/lib/hooks/use-ask.ts, line 58:

<comment>The Strict Mode fix itself is correct and minimal. Since this is framed as a bug fix and the project convention requires a regression test with real evidence for bug fixes, consider adding a test that exercises the mount → unmount → remount sequence and asserts that the mountedRef guards still allow the end-of-loading paths (stopStreaming on final_answer/complete, idle watchdog, error clear of isStreaming) to run. The current PR only notes that the existing 140-test suite still passes, which cannot catch this dev-only regression.</comment>

<file context>
@@ -52,6 +52,10 @@ export function useAsk() {
+    // Must be re-set on every mount: React Strict Mode mounts, unmounts and
+    // remounts in development, and the cleanup below would otherwise leave this
+    // false forever, making every mountedRef guard dead code.
+    mountedRef.current = true
     return () => {
       mountedRef.current = false
</file context>

Renders useAsk inside StrictMode so the mount → unmount → remount sequence
runs, then drives a mocked SSE stream to completion and to an in-band error
event. Both assertions fail on the pre-fix hook with `expected true to be
false`, because mountedRef.current stays false and every setState becomes a
no-op — the existing suite could not catch this since nothing exercised a
remount.

@cubic-dev-ai cubic-dev-ai 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.

No issues found across 2 files

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Re-trigger cubic

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