/** * Mandu useSSE Hook - ReadableStream SSE reading for island components * * Problem: When a React component (inside createRoot) reads a ReadableStream * in a tight `while(true) { await reader.read(); setState(...); }` loop, * React 19's internal scheduler uses `queueMicrotask` to process state updates. * Combined with fast SSE token streams, the microtask queue never drains, * starving the macrotask queue (DOM painting, user input, MessageChannel). * This effectively freezes the main thread. * * Solution: This hook yields to the macrotask queue between chunks using * `setTimeout(0)`, allowing the browser to paint and process user input * between state updates. Chunks arriving during the yield are coalesced * into a single state update to minimize re-renders. * * @module client/use-sse */ import { useState, useCallback, useRef, useEffect } from "react"; // ============================================================================ // Types // ============================================================================ export interface UseSSEOptions { /** * Called for each SSE chunk (after TextDecoder). * Return the new accumulated value to set as state. * @param accumulated - Current accumulated value * @param chunk - New text chunk from the stream * @returns Updated accumulated value */ onChunk?: (accumulated: string, chunk: string) => string; /** * Called when the stream completes. * @param finalValue - The final accumulated value */ onComplete?: (finalValue: string) => void; /** * Called when the stream errors. * @param error - The error that occurred */ onError?: (error: Error) => void; /** * Minimum interval between state updates in ms. * Higher values reduce re-renders but increase perceived latency. * Default: 0 (yield once per chunk via setTimeout(0)) */ throttleMs?: number; } export interface UseSSEReturn { /** Current accumulated stream data */ data: string; /** Whether the stream is currently active */ isStreaming: boolean; /** Error if the stream failed */ error: Error | null; /** * Start reading an SSE stream. * Pass a fetch Response or a ReadableStream. */ start: (source: Response | ReadableStream) => void; /** * Abort the current stream. */ abort: () => void; /** Reset state (data, error, isStreaming) */ reset: () => void; } // ============================================================================ // Helpers // ============================================================================ /** * Yield to the macrotask queue, allowing the browser to paint and * process user input. This breaks the microtask starvation cycle * caused by tight `await reader.read()` + `setState()` loops. */ function yieldToMacrotask(): Promise { return new Promise((resolve) => setTimeout(resolve, 0)); } // ============================================================================ // Hook // ============================================================================ /** * React hook for reading SSE / ReadableStream data inside island components * without blocking the main thread. * * @example * ```tsx * import { useSSE } from "@mandujs/core/client"; * * function ChatIsland() { * const { data, isStreaming, start } = useSSE({ * onComplete: (text) => console.log("Done:", text), * }); * * const sendMessage = async () => { * const res = await fetch("/api/chat", { method: "POST", body: ... }); * start(res); * }; * * return ( *
*

{data}

* {isStreaming && streaming...} * *
* ); * } * ``` */ export function useSSE(options: UseSSEOptions = {}): UseSSEReturn { const { onChunk = (acc: string, chunk: string) => acc + chunk, onComplete, onError, throttleMs = 0, } = options; const [data, setData] = useState(""); const [isStreaming, setIsStreaming] = useState(false); const [error, setError] = useState(null); // Refs for the current stream session (allow abort & prevent stale closures) const abortRef = useRef(null); const accumulatorRef = useRef(""); const pendingChunksRef = useRef(""); const flushScheduledRef = useRef(false); const optionsRef = useRef({ onChunk, onComplete, onError, throttleMs }); // Keep options ref in sync useEffect(() => { optionsRef.current = { onChunk, onComplete, onError, throttleMs }; }); // Cleanup on unmount useEffect(() => { return () => { if (abortRef.current) { abortRef.current.abort(); abortRef.current = null; } }; }, []); /** * Flush pending chunks as a single batched state update. * Uses setTimeout(0) to yield to the macrotask queue first. */ const flushPending = useCallback(() => { if (!flushScheduledRef.current) return; const pending = pendingChunksRef.current; if (pending) { const { onChunk: chunkFn } = optionsRef.current; accumulatorRef.current = chunkFn(accumulatorRef.current, pending); pendingChunksRef.current = ""; setData(accumulatorRef.current); } flushScheduledRef.current = false; }, []); /** * Schedule a flush via setTimeout to yield to macrotask queue. */ const scheduleFlush = useCallback(() => { if (!flushScheduledRef.current) { flushScheduledRef.current = true; setTimeout(flushPending, optionsRef.current.throttleMs); } }, [flushPending]); const start = useCallback( (source: Response | ReadableStream) => { // Abort any existing stream if (abortRef.current) { abortRef.current.abort(); } const controller = new AbortController(); abortRef.current = controller; accumulatorRef.current = ""; pendingChunksRef.current = ""; flushScheduledRef.current = false; setData(""); setError(null); setIsStreaming(true); const body = source instanceof Response ? source.body : source; if (!body) { setIsStreaming(false); setError(new Error("Response has no body")); return; } // Start reading in a properly-yielding async loop void (async () => { const reader = body.getReader(); const decoder = new TextDecoder(); try { while (true) { // Check for abort if (controller.signal.aborted) { await reader.cancel(); break; } const { done, value } = await reader.read(); if (done) break; // Check for abort again (may have been aborted during read) if (controller.signal.aborted) { await reader.cancel(); break; } const text = decoder.decode(value, { stream: true }); if (text) { // Accumulate chunks and schedule a batched flush pendingChunksRef.current += text; scheduleFlush(); } // CRITICAL: Yield to the macrotask queue to prevent microtask // starvation. Without this, the tight loop of // `await reader.read() -> setState -> React microtask -> next read` // starves the browser's macrotask queue, freezing the UI. await yieldToMacrotask(); } // Final flush for any remaining chunks if (pendingChunksRef.current) { flushScheduledRef.current = true; flushPending(); } if (!controller.signal.aborted) { setIsStreaming(false); const opts = optionsRef.current; if (opts.onComplete) { opts.onComplete(accumulatorRef.current); } } } catch (err) { if ( !controller.signal.aborted && !(err instanceof DOMException && err.name === "AbortError") ) { const error = err instanceof Error ? err : new Error(String(err)); setError(error); setIsStreaming(false); const opts = optionsRef.current; if (opts.onError) { opts.onError(error); } } } })(); }, [scheduleFlush, flushPending] ); const abort = useCallback(() => { if (abortRef.current) { abortRef.current.abort(); abortRef.current = null; } setIsStreaming(false); }, []); const reset = useCallback(() => { abort(); setData(""); setError(null); accumulatorRef.current = ""; pendingChunksRef.current = ""; flushScheduledRef.current = false; }, [abort]); return { data, isStreaming, error, start, abort, reset }; } // ============================================================================ // Low-level utility (for non-hook usage) // ============================================================================ /** * Read a ReadableStream with macrotask yielding, suitable for use * outside of React components (e.g., in plain event handlers). * * @example * ```ts * import { readStreamWithYield } from "@mandujs/core/client"; * * const response = await fetch("/api/stream"); * await readStreamWithYield(response.body!, { * onChunk: (text) => { * // update DOM directly * el.textContent += text; * }, * onDone: () => console.log("done"), * }); * ``` */ export interface ReadStreamOptions { /** Called for each decoded text chunk */ onChunk: (text: string) => void; /** Called when the stream is complete */ onDone?: () => void; /** Called on error */ onError?: (error: Error) => void; /** AbortSignal to cancel reading */ signal?: AbortSignal; } export async function readStreamWithYield( stream: ReadableStream, options: ReadStreamOptions ): Promise { const { onChunk, onDone, onError, signal } = options; const reader = stream.getReader(); const decoder = new TextDecoder(); try { while (true) { if (signal?.aborted) { await reader.cancel(); break; } const { done, value } = await reader.read(); if (done) break; if (signal?.aborted) { await reader.cancel(); break; } const text = decoder.decode(value, { stream: true }); if (text) { onChunk(text); } // Yield to macrotask queue await yieldToMacrotask(); } if (!signal?.aborted) { onDone?.(); } } catch (err) { if ( !signal?.aborted && !(err instanceof DOMException && err.name === "AbortError") ) { const error = err instanceof Error ? err : new Error(String(err)); onError?.(error); } } }