diff --git a/packages/loopover-ui-kit/src/hooks/use-streaming-text.test.ts b/packages/loopover-ui-kit/src/hooks/use-streaming-text.test.ts index 58446f86e9..00baecbe2f 100644 --- a/packages/loopover-ui-kit/src/hooks/use-streaming-text.test.ts +++ b/packages/loopover-ui-kit/src/hooks/use-streaming-text.test.ts @@ -122,4 +122,70 @@ describe("useStreamingText (#6516)", () => { expect(result.current.error?.message).toBe("stream boom"); expect(result.current.text).toBe("partial"); }); + + describe("cancel() after settle is a no-op (#10050)", () => { + it("cancel() on a null source leaves status idle", () => { + const { result } = renderHook(() => useStreamingText(null)); + expect(result.current.status).toBe("idle"); + + act(() => result.current.cancel()); + + expect(result.current.status).toBe("idle"); + expect(result.current.text).toBe(""); + expect(result.current.error).toBe(null); + }); + + it("cancel() on a completed stream leaves status done and text unchanged", async () => { + const src = deferredSource(); + const { result } = renderHook(() => useStreamingText(src.source)); + await src.push("Hello"); + await waitFor(() => expect(result.current.text).toBe("Hello")); + await src.finish(); + await waitFor(() => expect(result.current.status).toBe("done")); + + act(() => result.current.cancel()); + + expect(result.current.status).toBe("done"); + expect(result.current.text).toBe("Hello"); + }); + + it("cancel() on a failed stream leaves status error and the error intact", async () => { + const src = deferredSource(); + const { result } = renderHook(() => useStreamingText(src.source)); + await src.push("partial"); + await waitFor(() => expect(result.current.text).toBe("partial")); + await src.fail(new Error("stream boom")); + await waitFor(() => expect(result.current.status).toBe("error")); + + act(() => result.current.cancel()); + + expect(result.current.status).toBe("error"); + expect(result.current.error?.message).toBe("stream boom"); + }); + + it("swapping in a new source after a settled cancel() still starts and can be cancelled", async () => { + const first = deferredSource(); + const { result, rerender } = renderHook( + ({ s }: { s: ChunkSource | null }) => useStreamingText(s), + { initialProps: { s: first.source as ChunkSource | null } }, + ); + await first.push("old"); + await waitFor(() => expect(result.current.text).toBe("old")); + await first.finish(); + await waitFor(() => expect(result.current.status).toBe("done")); + + act(() => result.current.cancel()); // settled cancel — no-op + expect(result.current.status).toBe("done"); + + const second = deferredSource(); + rerender({ s: second.source }); + await waitFor(() => expect(result.current.status).toBe("streaming")); + + await second.push("new"); + await waitFor(() => expect(result.current.text).toBe("new")); + + act(() => result.current.cancel()); + await waitFor(() => expect(result.current.status).toBe("cancelled")); + }); + }); }); diff --git a/packages/loopover-ui-kit/src/hooks/use-streaming-text.ts b/packages/loopover-ui-kit/src/hooks/use-streaming-text.ts index 03c3923517..6c69a714c1 100644 --- a/packages/loopover-ui-kit/src/hooks/use-streaming-text.ts +++ b/packages/loopover-ui-kit/src/hooks/use-streaming-text.ts @@ -16,7 +16,11 @@ export interface StreamingTextState { text: string; status: StreamingStatus; error: Error | null; - /** Stop consuming the current source; no later chunk from it reaches state. Idempotent, safe post-unmount. */ + /** + * Stop consuming the current source; no later chunk from it reaches state. Idempotent, safe + * post-unmount. A no-op once the stream has already settled (reached `idle`, `done` or `error` + * for the current source) — it never overwrites a settled status with `"cancelled"`. + */ cancel: () => void; } @@ -39,8 +43,11 @@ export function useStreamingText( // Per-effect flag (a fresh closure each run): the cleanup below flips it on a new source or unmount, so the // previous run's worker stops and writes no more state. cancel() flips this same flag for an explicit stop. let cancelled = false; + // Per-effect flag mirroring `cancelled`, but set on each terminal transition (idle/done/error) instead of + // by cancel()/cleanup. Once the stream has settled, cancel() must leave status/text/error untouched. + let settled = false; cancelRef.current = () => { - if (!cancelled) { + if (!cancelled && !settled) { cancelled = true; setStatus("cancelled"); } @@ -55,6 +62,7 @@ export function useStreamingText( setText(""); setError(null); if (!source) { + settled = true; setStatus("idle"); return; } @@ -64,9 +72,13 @@ export function useStreamingText( if (cancelled) return; setText((prev) => prev + chunk); } - if (!cancelled) setStatus("done"); + if (!cancelled) { + settled = true; + setStatus("done"); + } } catch (err) { if (!cancelled) { + settled = true; setError(err instanceof Error ? err : new Error(String(err))); setStatus("error"); }