Skip to content

fix: don't set state in useStreamdownMarkdown's effect (Maximum update depth exceeded while streaming) - #38

Open
petar-studocu wants to merge 2 commits into
software-mansion-labs:mainfrom
petar-studocu:fix/no-setstate-in-effect
Open

petar-studocu wants to merge 2 commits into
software-mansion-labs:mainfrom
petar-studocu:fix/no-setstate-in-effect

Conversation

@petar-studocu

@petar-studocu petar-studocu commented Nov 4, 2026 •

Copy link
Copy Markdown

Summary

useStreamdownMarkdown calls setIsStreaming(true) inside its effect on every markdown change. With a fast-streaming source this can make React throw "Maximum update depth exceeded" mid-stream. This PR only sets state from the worklet callback and derives isStreaming from which markdown the last processed result belongs to.

The bug

The usual way to stream into StreamdownText is AI SDK useChat, which pushes each chunk through useSyncExternalStore. That makes every chunk a SyncLane render.

  1. React flushes a sync commit's passive effects before it checks for leftover work.
  2. The effect's setIsStreaming(true) is still pending at that check, so React counts the commit as a nested update.
  3. On a busy JS thread (low-end Android, long chat threads), the scheduler runs the next chunk before anything commits clean in between. The count then climbs by one per chunk.
  4. Past React's limit of 50, the next setState throws. That is usually the chat store's own update, so the stream fails.

In our app (Studocu), this caused about 25% of Ace chat replies to fail on low-end Android after we adopted StreamdownText.

The fix

  • The effect only schedules the worklet job. setResult({source, processed}) is called only from the worklet callback.
  • Any result newer than the one shown is applied, not only the latest. On a busy JS thread a worklet round-trip can take longer than the gap between chunks, and a latest-only check would then drop every result and freeze the text until the stream pauses. Older results are ignored.
  • isStreaming is now result.source !== markdown: true until the latest markdown has been processed. The meaning is the same as before.
  • For empty markdown the hook returns {processedMarkdown: '', isStreaming: false} directly, and results from jobs started before the clear are dropped.
  • The remendConfigKey gating from fix: depend on remendConfig content instead of its identity #30 is unchanged.

Tests

  • New test in src/__tests__/useStreamdownMarkdown.test.tsx: mounting and each markdown change render exactly once until the worklet result arrives. It also checks processedMarkdown and isStreaming through a full cycle.
    • Before: fails, because every change adds an extra render from the effect.
    • After: passes.
  • New test: results that arrive after the next change are still shown, out-of-order older results are ignored, and results from before a clear are dropped. It fails with a latest-only check.
  • yarn test, yarn typecheck and yarn lint pass.
  • In our app, a regression test that streams 120 chunks through the real StreamdownText into a long thread threw at chunk 53 on 0.2.0 and passes with this change applied as a patch.

🤖 Generated with Claude Code

petar-studocu and others added 2 commits November 4, 2026 11:39
The effect called setIsStreaming(true) on every markdown change, so each
chunk rendered twice. When the markdown comes from a synchronous store
(e.g. useSyncExternalStore in AI SDK useChat) and the JS thread is busy,
each of those follow-up updates is left pending when the chunk's sync
commit finishes, React counts it as a nested update, and after ~50 chunks
it throws "Maximum update depth exceeded" mid-stream.

State is now only set from the worklet callback, and isStreaming is derived
from which markdown the last processed result belongs to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a worklet round-trip takes longer than the gap between markdown
updates (a fast stream on a busy JS thread), the latest-only check drops
every result and the text freezes until the stream pauses. Apply any
result newer than the one shown instead; clearing the markdown marks
older results as stale. isStreaming stays true until the latest markdown
has been processed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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