Skip to content

Commit b582748

Browse files
committed
fix(appkit-ui): chat primitives robustness
- responses-api-transport: normalize CRLF to LF in the SSE line parser so the "\n\n" frame-boundary split works behind proxies that re-emit events with CRLF line endings. Adds a CRLF-stream unit test. - use-scroll-to-bottom: switch from useRef + listener-attach-on-mount to a state-backed ref callback. The listener-attach effect now re-runs when the container DOM node changes, so consumers that conditionally render the scroll container (e.g. behind a loading gate) still get auto-stick. The public ref type changes from RefObject<T | null> to (node: T | null) => void; JSX `ref={containerRef}` keeps working because React accepts both shapes, and no consumer reads `.current` (verified across the repo). Signed-off-by: Hubert Zub <hubert.zub@databricks.com>
1 parent c70492a commit b582748

3 files changed

Lines changed: 89 additions & 24 deletions

File tree

Lines changed: 36 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useEffect, useRef, useState } from "react";
1+
import { useCallback, useEffect, useState } from "react";
22

33
export interface UseScrollToBottomOptions {
44
/** Pixels from the bottom that still count as "at bottom". Default 50. */
@@ -8,7 +8,13 @@ export interface UseScrollToBottomOptions {
88
}
99

1010
export interface UseScrollToBottomReturn<T extends HTMLElement> {
11-
containerRef: React.RefObject<T | null>;
11+
/**
12+
* Ref callback. Attach with `ref={containerRef}` on the scroll
13+
* container. Re-runs the listener-attach effect whenever the
14+
* underlying DOM node changes — so containers that mount after the
15+
* first render (e.g. behind a `loading` gate) still get the listener.
16+
*/
17+
containerRef: (node: T | null) => void;
1218
isAtBottom: boolean;
1319
scrollToBottom: (behavior?: ScrollBehavior) => void;
1420
}
@@ -21,42 +27,49 @@ export function useScrollToBottom<T extends HTMLElement = HTMLDivElement>({
2127
threshold = 50,
2228
trigger,
2329
}: UseScrollToBottomOptions = {}): UseScrollToBottomReturn<T> {
24-
const containerRef = useRef<T | null>(null);
30+
// State-backed so changes to the underlying DOM node re-run the
31+
// listener-attach effect (e.g. when the container mounts after the
32+
// first render).
33+
const [element, setElement] = useState<T | null>(null);
2534
const [isAtBottom, setIsAtBottom] = useState(true);
2635

27-
const handleScroll = useCallback(() => {
28-
const el = containerRef.current;
29-
if (!el) return;
30-
setIsAtBottom(el.scrollHeight - el.scrollTop - el.clientHeight < threshold);
31-
}, [threshold]);
36+
const containerRef = useCallback((node: T | null) => {
37+
setElement(node);
38+
}, []);
3239

3340
useEffect(() => {
34-
const el = containerRef.current;
35-
if (!el) return;
36-
el.addEventListener("scroll", handleScroll, { passive: true });
41+
if (!element) return;
42+
const handleScroll = () => {
43+
setIsAtBottom(
44+
element.scrollHeight - element.scrollTop - element.clientHeight <
45+
threshold,
46+
);
47+
};
48+
element.addEventListener("scroll", handleScroll, { passive: true });
3749
handleScroll();
38-
return () => el.removeEventListener("scroll", handleScroll);
39-
}, [handleScroll]);
50+
return () => element.removeEventListener("scroll", handleScroll);
51+
}, [element, threshold]);
4052

4153
useEffect(() => {
4254
// `trigger` is referenced only to opt the effect into a re-run when
4355
// its identity changes (e.g. on every new message during streaming).
4456
void trigger;
45-
if (isAtBottom && containerRef.current) {
57+
if (isAtBottom && element) {
4658
// `instant` here so streaming re-renders don't queue smooth animations.
47-
containerRef.current.scrollTo({
48-
top: containerRef.current.scrollHeight,
59+
element.scrollTo({
60+
top: element.scrollHeight,
4961
behavior: "instant",
5062
});
5163
}
52-
}, [trigger, isAtBottom]);
64+
}, [trigger, isAtBottom, element]);
5365

54-
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
55-
containerRef.current?.scrollTo({
56-
top: containerRef.current.scrollHeight,
57-
behavior,
58-
});
59-
}, []);
66+
const scrollToBottom = useCallback(
67+
(behavior: ScrollBehavior = "smooth") => {
68+
if (!element) return;
69+
element.scrollTo({ top: element.scrollHeight, behavior });
70+
},
71+
[element],
72+
);
6073

6174
return { containerRef, isAtBottom, scrollToBottom };
6275
}

packages/appkit-ui/src/react/chat/lib/responses-api-transport.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,4 +566,54 @@ describe("ResponsesApiTransport", () => {
566566
}
567567
expect(chunks.map((c) => c.type)).toEqual(["start", "finish"]);
568568
});
569+
570+
test("parses CRLF-encoded SSE frames (proxy normalization)", async () => {
571+
const encoder = new TextEncoder();
572+
const body =
573+
`id: 1\r\nevent: response.output_item.added\r\ndata: ${JSON.stringify({
574+
type: "response.output_item.added",
575+
output_index: 0,
576+
item: messageItem("msg_a"),
577+
sequence_number: 0,
578+
})}\r\n\r\n` +
579+
`id: 2\r\nevent: response.output_text.delta\r\ndata: ${JSON.stringify({
580+
type: "response.output_text.delta",
581+
item_id: "msg_a",
582+
output_index: 0,
583+
content_index: 0,
584+
delta: "hi",
585+
sequence_number: 1,
586+
})}\r\n\r\n` +
587+
`id: 3\r\nevent: response.completed\r\ndata: ${JSON.stringify({
588+
type: "response.completed",
589+
sequence_number: 2,
590+
response: {},
591+
})}\r\n\r\n`;
592+
const stream = new ReadableStream<Uint8Array>({
593+
start(controller) {
594+
controller.enqueue(encoder.encode(body));
595+
controller.close();
596+
},
597+
});
598+
const transport = new TestableTransport<UIMessage>({ api: "/test" });
599+
const out = transport.process(stream);
600+
const reader = out.getReader();
601+
const chunks: UIMessageChunk[] = [];
602+
while (true) {
603+
const { done, value } = await reader.read();
604+
if (done) break;
605+
chunks.push(value);
606+
}
607+
expect(chunks.map((c) => c.type)).toEqual([
608+
"start",
609+
"text-start",
610+
"text-delta",
611+
"finish",
612+
]);
613+
expect(chunks).toContainEqual({
614+
type: "text-delta",
615+
id: "msg_a",
616+
delta: "hi",
617+
});
618+
});
569619
});

packages/appkit-ui/src/react/chat/lib/responses-api-transport.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,9 @@ function sseEventLineParser(): TransformStream<string, ResponseStreamEvent> {
123123
let buffer = "";
124124
return new TransformStream({
125125
transform(chunk, controller) {
126-
buffer += chunk;
126+
// Normalize CRLF → LF so the `\n\n` boundary split works behind
127+
// intermediaries that re-emit SSE frames with CRLF line endings.
128+
buffer += chunk.replace(/\r\n/g, "\n");
127129
let separatorIdx: number;
128130
// biome-ignore lint/suspicious/noAssignInExpressions: standard SSE buffer drain pattern
129131
while ((separatorIdx = buffer.indexOf("\n\n")) !== -1) {

0 commit comments

Comments
 (0)