Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions packages/loopover-ui-kit/src/hooks/use-streaming-text.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
});
});
});
18 changes: 15 additions & 3 deletions packages/loopover-ui-kit/src/hooks/use-streaming-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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");
}
Expand All @@ -55,6 +62,7 @@ export function useStreamingText(
setText("");
setError(null);
if (!source) {
settled = true;
setStatus("idle");
return;
}
Expand All @@ -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");
}
Expand Down
Loading