Skip to content

fix(chat): abort streaming fetch on unmount / stopGenerating#115

Closed
zachdive wants to merge 3 commits into
masterfrom
claude/intelligent-babbage-2ea8d0
Closed

fix(chat): abort streaming fetch on unmount / stopGenerating#115
zachdive wants to merge 3 commits into
masterfrom
claude/intelligent-babbage-2ea8d0

Conversation

@zachdive
Copy link
Copy Markdown
Contributor

@zachdive zachdive commented Apr 22, 2026

Summary

  • Wire an AbortController into useCreativeChatMutation and useParametricChatMutation so the streaming fetch + reader are cancelled when the view unmounts or stopGenerating fires.
  • Fixes the "stuck loading forever" bug where a mid-stream unmount (error boundary from the react-resizable-panels crash, route change, tab close before pagehide) left the mutation orphaned — useIsMutating(['parametric-chat', conversationId]) stayed truthy and isLoading never cleared.

What changed

  • Module-level Map<conversationId, AbortController> registry + exported abortActiveStream(id) so the View can abort externally.
  • signal: controller.signal threaded through both fetch calls; catch block tags abort as AbortError and onError short-circuits — no Sentry noise, no "An error occurred" placeholder message written to the thread.
  • useEffect unmount cleanup in each hook invokes abortActiveStream(conversationId) — this is the path that actually fixes the orphan case.
  • stopGenerating in ParametricEditorView and CreativeEditorView now aborts client-side before the existing cancelRequest realtime signal (client drops connection → server-side signal tells the edge function to wind down).
  • Streaming reader itself is untouched — scope is cancellation plumbing only.

Test plan

⚠️ Author could not test locally — browser verification still owed before merge.

  • Start a long parametric generation, navigate away mid-stream; confirm Network tab shows the request cancelled and useIsMutating returns 0.
  • Trigger an error during stream (throw in the reader loop); confirm no unhandled rejection, mutation settles cleanly.
  • Verify the stopGenerating button still works end-to-end for both parametric and creative chat.
  • Verify a stream that completes normally (no abort) still runs onSuccess and updates the conversation leaf as before.

🤖 Generated with Claude Code


Summary by cubic

Abort streaming chat requests on unmount and when Stop Generating is pressed to prevent orphaned mutations and stuck loading states. Adds a per-conversation AbortController and ensures aborts are silent and never commit partial messages.

  • Bug Fixes
    • Cancel streaming via AbortController on unmount and stopGenerating; views call abortActiveStream(conversationId) before cancelRequest.
    • Threaded signal into fetch; aborts throw AbortError and are ignored by onError (no Sentry, no user error).
    • Removed shadowed conversationId param from mutation fns to avoid abort registry misses; callers no longer pass it.
    • Fixed orphaned mutations where useIsMutating(['parametric-chat', conversationId]) stayed truthy after mid-stream unmount.
    • Hardened abort detection: isAbortError uses instanceof Error; dropped signal.aborted fallback.

Written for commit 375b5db. Summary will update on new commits.

Wire an AbortController into useCreativeChatMutation and
useParametricChatMutation so the fetch + stream reader are cancelled
when the driving view unmounts or stopGenerating fires. Previously a
mid-stream unmount (error boundary, route change) left the mutation
orphaned — useIsMutating stayed truthy and the UI appeared stuck
loading forever.

- Module-level Map<conversationId, AbortController> + exported
  abortActiveStream(conversationId) helper.
- Signal threaded through fetch; AbortError short-circuits onError so
  cancellation is silent (no Sentry, no placeholder message).
- useEffect unmount cleanup calls abortActiveStream per hook instance.
- Views' stopGenerating aborts client-side before the existing
  cancelRequest realtime signal.

Streaming reader itself is untouched — only cancellation plumbing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 22, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cadam Ready Ready Preview, Comment Apr 22, 2026 7:55pm

Request Review

@supabase
Copy link
Copy Markdown

supabase Bot commented Apr 22, 2026

This pull request has been ignored for the connected project sgprnbvihmydyrzvkcir because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 22, 2026

Greptile Summary

Adds AbortController plumbing to both chat mutations so that unmounting or pressing Stop Generating cancels the in-flight fetch and stream reader, fixing the orphaned-mutation/stuck-loading bug. The architecture (module-level registry, useEffect cleanup, onError guard) is sound, but there is one defect that defeats the entire abort handling path.

  • P1 — isAbortError is broken for browser fetch aborts: fetch() and reader.read() abort by throwing a DOMException (name === 'AbortError'), not a regular Error. DOMException does not extend Error in browsers, so error instanceof Error is false, isAbortError() returns false, and every abort falls through to Sentry + error-message insertion — the exact behaviours the PR intended to suppress.

Confidence Score: 4/5

Not safe to merge until isAbortError is fixed; the abort guard is silently broken in the browser.

One P1 defect: isAbortError only checks instanceof Error, which will be false for every DOMException thrown by the Fetch API on abort, causing Sentry noise and error placeholder insertion on every cancelled stream. The rest of the plumbing (registry, useEffect cleanup, signal threading, view-level calls) is correct.

src/services/messageService.ts — the isAbortError function at line 29.

Important Files Changed

Filename Overview
src/services/messageService.ts Core abort plumbing added: AbortController registry, signal threading, isAbortError guard — but isAbortError uses instanceof Error which misses DOMException, breaking abort detection for browser fetch/reader aborts entirely.
src/views/CreativeEditorView.tsx Minimal, correct change: imports abortActiveStream, calls it before cancelRequest in stopGenerating, adds conversation.id to useCallback deps.
src/views/ParametricEditorView.tsx Mirror of CreativeEditorView change — abortActiveStream wired into stopGenerating correctly, deps updated.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["stopGenerating() / unmount"] --> B["abortActiveStream(conversationId)"]
    B --> C["controller.abort()"]
    B --> D["delete from activeStreamControllers"]
    C --> E["fetch / reader.read() throws DOMException"]
    E --> F{"isAbortError(error)?\nerror instanceof Error && name==='AbortError'"}
    F -->|"DOMException → instanceof Error = false\n❌ BROKEN PATH"| G["re-throw as real error"]
    G --> H["onError fires WITHOUT guard"]
    H --> I["Sentry.captureException 🔔"]
    H --> J["insertMessageAsync 'An error occurred' 💬"]
    F -->|"After fix: instanceof DOMException check"| K["wrap & re-throw AbortError"]
    K --> L["onError → isAbortError → early return ✅"]
    L --> M["No Sentry, no error message ✅"]
Loading

Reviews (3): Last reviewed commit: "fix(chat): address greptile v2 — shadowi..." | Re-trigger Greptile

Comment thread src/services/messageService.ts
Comment thread src/services/messageService.ts Outdated
Copy link
Copy Markdown

@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 3 files

- isAbortError: replace `as` cast with `instanceof Error` per team rule.
- Drop `controller.signal.aborted` fallback in the abort detection so a
  real error that happens to coincide with an aborted signal is not
  silently dropped. AbortError.name alone is sufficient — both the
  fetch-side DOMException and our manually-thrown error carry it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Drop the inner `conversationId` parameter from both chat mutationFn
  signatures. It shadowed the outer hook binding used by the useEffect
  cleanup and would silently miss the registry entry if ever called with
  a mismatched id. All three call sites (send/retry/edit) already passed
  `conversation.id`, so removing the key is a safe tightening.
- In the abort catch, always throw a tagged AbortError instead of
  returning the partial `finalMessage`. Returning partial triggered
  onSuccess → messageInsertedConversationUpdate, which wrote the
  partial as the conversation leaf into the sidebar cache — diverging
  from DB when the server honors the abort before persisting. onError
  already filters AbortError silently.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment on lines +29 to +31
function isAbortError(error: unknown): boolean {
return error instanceof Error && error.name === 'AbortError';
}
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.

P1 isAbortError will not match the DOMException thrown by browser fetch/reader abort

In browsers, fetch() (and reader.read()) abort by throwing a DOMException with name === 'AbortError'not a regular Error. DOMException does not extend Error on the browser's prototype chain, so error instanceof Error evaluates to false for every real abort thrown by the Fetch API. isAbortError() then returns false, the catch block re-throws the DOMException as a live error, onError skips its guard, Sentry fires, and the "An error occurred" placeholder is inserted — the exact behaviours this PR was meant to suppress.

Suggested change
function isAbortError(error: unknown): boolean {
return error instanceof Error && error.name === 'AbortError';
}
function isAbortError(error: unknown): boolean {
return (error instanceof DOMException || error instanceof Error) && error.name === 'AbortError';
}

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