Skip to content

fix: useStream can now handle multiple streams per component lifetime - #20

Merged
ianmacartney merged 1 commit into
mainfrom
cursor/text-streaming-persistence-on-disconnect-4721
Apr 7, 2026
Merged

ianmacartney merged 1 commit into
mainfrom
cursor/text-streaming-persistence-on-disconnect-4721

Conversation

@ianmacartney

@ianmacartney ianmacartney commented Mar 25, 2026 •

Copy link
Copy Markdown
Member

Summary

Fixes #19

The useStream hook was unable to drive more than one stream per component lifetime. After the first stream completed, subsequent calls with a new streamId silently skipped the HTTP POST and fell back to the database query, losing the real-time streaming UX.

Root Causes Addressed

1. streamStarted ref was set but never cleared

The original code used a streamStarted ref intended as a React Strict Mode guard, but it permanently blocked all future streams after the first one completed.

Fix: Replaced with activeStreamRef that tracks the current streamId. This serves as both:

  • A Strict Mode guard (same streamId won't re-trigger)
  • A mechanism to detect when a new stream should be started

2. streamBody and streamEnded were never reset between streams

When streamId changed, the state from the previous stream would persist, potentially causing text concatenation issues.

Fix: Both states are now reset when a new streamId is detected.

3. No AbortController caused stale stream reads to leak

If the component re-rendered with a new streamId while a previous fetch was mid-read, the old reader.read() loop would continue calling setStreamBody, mixing text across streams.

Fix: Added AbortController to cleanup in-flight requests when:

  • Component unmounts
  • streamId changes to a new value

Errors from aborted fetches are silently ignored to avoid false error states.

4. Minor performance improvement

TextDecoder was being allocated per chunk inside the while loop.

Fix: Now allocated once per stream and reused.

Testing

  • TypeScript type checking passes (npm run typecheck)
  • ESLint passes (npm run lint)
  • Tests pass (npm test)

Slack Thread

Open in Web Open in Cursor 

Summary by CodeRabbit

  • Bug Fixes
    • Refined stream tracking mechanism for more reliable handling of sequential streams
    • Enhanced error detection and reporting for various streaming failure scenarios
    • Improved resource cleanup during stream transitions and component lifecycle to prevent memory leaks

Fixes #19

Root causes addressed:
1. streamStarted ref was set but never cleared, blocking all future streams
   - Replaced with activeStreamRef that tracks the current streamId
   - Acts as both a Strict Mode guard AND allows detecting new streams

2. streamBody and streamEnded were never reset between streams
   - Now reset both states when a new streamId is detected

3. No AbortController caused stale stream reads to leak
   - Added AbortController to cleanup in-flight requests when:
     - Component unmounts
     - streamId changes to a new value
   - Errors from aborted fetches are now silently ignored

4. TextDecoder was allocated per chunk (minor perf issue)
   - Now allocated once per stream and reused

The fix enables the real-time streaming UX for multiple consecutive
messages in the same component session, rather than falling back to
database persistence after the first stream completes.

Co-authored-by: Ian Macartney <ianmacartney@users.noreply.github.com>
@coderabbitai

coderabbitai Bot commented Mar 25, 2026 •

Copy link
Copy Markdown

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5c6f231f-8532-4d26-ba43-105719e688a1

📥 Commits

Reviewing files that changed from the base of the PR and between 374195a and cbb178d.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (1)
  • src/react/index.ts

📝 Walkthrough

Walkthrough

Refactored the useStream hook to replace a streamStarted boolean ref with activeStreamRef tracking the current streamId, added inline fetch logic with AbortController for request cancellation, and corrected state reset behavior between stream invocations to prevent concatenation and duplicate prevention issues.

Changes

Cohort / File(s) Summary
Stream Ref and State Management
src/react/index.ts
Replaced streamStarted ref guard with activeStreamRef tracking active streamId. Added early returns when driven or streamId are not set, and skip restart when streamId matches activeStreamRef.current. Reset streamBody to "" and streamEnded to null when encountering a new streamId.
Inline Streaming Logic
src/react/index.ts
Moved streaming logic directly into useEffect, removing the startStreaming helper. Implemented fetch with AbortController signal, direct ReadableStream reader for response.body, and text chunk decoding/appending. Added cleanup via controller.abort() on unmount and effect re-run.
Error Handling and Completion
src/react/index.ts
Adjusted state transitions: HTTP 205, non-OK responses, missing body, or read errors now set streamEnded to false; successful completion sets streamEnded to true. Errors during abort are silently caught to prevent state updates on cancelled requests.

Sequence Diagram

sequenceDiagram
    participant Component as Component
    participant Effect as useEffect Hook
    participant Controller as AbortController
    participant Fetch as Fetch API
    participant Stream as ReadableStream Reader
    participant State as State Management

    Component->>Effect: streamId changes
    Effect->>Effect: Check activeStreamRef vs streamId
    alt New streamId detected
        Effect->>State: Reset streamBody & streamEnded
        Effect->>Controller: Create new AbortController
        Effect->>Fetch: POST request with signal
        Fetch-->>Stream: response.body stream
        
        loop Read chunks
            Stream->>Stream: await reader.read()
            alt Chunk received
                Stream->>State: Append text to streamBody
            else No more chunks
                Stream->>State: Set streamEnded = true
                Stream->>Stream: Break loop
            end
        end
    else Matching streamId
        Effect->>Effect: Skip (Strict Mode guard)
    end
    
    Component->>Effect: Unmount/New effect
    Effect->>Controller: controller.abort()
    Controller-->>Fetch: Cancel in-flight request
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Poem

🐰 A stream that once got stuck in place,
Now flows with grace throughout the race!
No more refs that block the way—
Each new stream runs fresh today.
With AbortController's gentle hand,
Our hopping code now works as planned! 🌊✨

✨ 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 cursor/text-streaming-persistence-on-disconnect-4721

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 and usage tips.

@pkg-pr-new

pkg-pr-new Bot commented Mar 25, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@convex-dev/persistent-text-streaming@20

commit: cbb178d

@Pierozi

Pierozi commented Apr 7, 2026

Copy link
Copy Markdown

Thanks, I confirm the fix is working

@ianmacartney
ianmacartney marked this pull request as ready for review April 7, 2026 23:27
@ianmacartney
ianmacartney merged commit 1e8581a into main Apr 7, 2026
3 checks passed
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.

useStream: streamStarted ref never resets — HTTP POST only fires once per mount

3 participants