Skip to content

Commit 6c99581

Browse files
authoredJun 28, 2024··
fix (ai/react): stop() on useObject does not throw error and clear isLoading (#2132)
1 parent 3cee4c3 commit 6c99581

File tree

5 files changed

+126
-24
lines changed

5 files changed

+126
-24
lines changed
 

‎.changeset/empty-ravens-melt.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@ai-sdk/react': patch
3+
'ai': patch
4+
---
5+
6+
fix (ai/react): stop() on useObject does not throw error and clears isLoading

‎examples/next-openai/app/use-object/page.tsx

+20-4
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,36 @@ import { experimental_useObject as useObject } from 'ai/react';
44
import { notificationSchema } from '../api/use-object/schema';
55

66
export default function Page() {
7-
const { setInput, object } = useObject({
7+
const { submit, isLoading, object, stop } = useObject({
88
api: '/api/use-object',
99
schema: notificationSchema,
1010
});
1111

1212
return (
1313
<div className="flex flex-col items-center min-h-screen p-4 m-4">
1414
<button
15-
className="px-4 py-2 mt-4 text-white bg-blue-500 rounded-md"
15+
className="px-4 py-2 mt-4 text-white bg-blue-500 rounded-md disabled:bg-blue-200"
1616
onClick={async () => {
17-
setInput('Messages during finals week.');
17+
submit('Messages during finals week.');
1818
}}
19+
disabled={isLoading}
1920
>
2021
Generate notifications
2122
</button>
2223

24+
{isLoading && (
25+
<div className="mt-4 text-gray-500">
26+
<div>Loading...</div>
27+
<button
28+
type="button"
29+
className="px-4 py-2 mt-4 text-blue-500 border border-blue-500 rounded-md"
30+
onClick={() => stop()}
31+
>
32+
STOP
33+
</button>
34+
</div>
35+
)}
36+
2337
<div className="flex flex-col gap-4 mt-4">
2438
{object?.notifications?.map((notification, index) => (
2539
<div
@@ -28,7 +42,9 @@ export default function Page() {
2842
>
2943
<div className="flex-1 space-y-1">
3044
<div className="flex items-center justify-between">
31-
<p className="font-medium">{notification?.name}</p>
45+
<p className="font-medium dark:text-white">
46+
{notification?.name}
47+
</p>
3248
<p className="text-sm text-gray-500 dark:text-gray-400">
3349
{notification?.minutesAgo}
3450
{notification?.minutesAgo != null ? ' minutes ago' : ''}

‎packages/react/src/use-object.ts

+14-5
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { isAbortError } from '@ai-sdk/provider-utils';
12
import {
23
DeepPartial,
34
FetchFunction,
@@ -101,13 +102,19 @@ function useObject<RESULT, INPUT = any>({
101102
const abortControllerRef = useRef<AbortController | null>(null);
102103

103104
const stop = useCallback(() => {
104-
abortControllerRef.current?.abort();
105-
abortControllerRef.current = null;
105+
try {
106+
abortControllerRef.current?.abort();
107+
} catch (ignored) {
108+
} finally {
109+
setIsLoading(false);
110+
abortControllerRef.current = null;
111+
}
106112
}, []);
107113

108114
const submit = async (input: INPUT) => {
109115
try {
110116
setIsLoading(true);
117+
setError(undefined);
111118

112119
const abortController = new AbortController();
113120
abortControllerRef.current = abortController;
@@ -133,7 +140,7 @@ function useObject<RESULT, INPUT = any>({
133140
let accumulatedText = '';
134141
let latestObject: DeepPartial<RESULT> | undefined = undefined;
135142

136-
response.body.pipeThrough(new TextDecoderStream()).pipeTo(
143+
await response.body.pipeThrough(new TextDecoderStream()).pipeTo(
137144
new WritableStream<string>({
138145
write(chunk) {
139146
accumulatedText += chunk;
@@ -155,9 +162,11 @@ function useObject<RESULT, INPUT = any>({
155162
},
156163
}),
157164
);
158-
159-
setError(undefined);
160165
} catch (error) {
166+
if (isAbortError(error)) {
167+
return;
168+
}
169+
161170
setError(error);
162171
}
163172
};

‎packages/react/src/use-object.ui.test.tsx

+69-3
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { experimental_useObject } from './use-object';
99

1010
describe('text stream', () => {
1111
const TestComponent = () => {
12-
const { object, error, submit, isLoading } = experimental_useObject({
12+
const { object, error, submit, isLoading, stop } = experimental_useObject({
1313
api: '/api/use-object',
1414
schema: z.object({ content: z.string() }),
1515
});
@@ -25,6 +25,9 @@ describe('text stream', () => {
2525
>
2626
Generate
2727
</button>
28+
<button data-testid="stop-button" onClick={stop}>
29+
Stop
30+
</button>
2831
</div>
2932
);
3033
};
@@ -79,7 +82,7 @@ describe('text stream', () => {
7982
});
8083

8184
server = setupServer(
82-
http.post('https://example.com/api/use-object', ({ request }) => {
85+
http.post('/api/use-object', ({ request }) => {
8386
return new HttpResponse(stream.pipeThrough(new TextEncoderStream()), {
8487
status: 200,
8588
headers: {
@@ -101,7 +104,7 @@ describe('text stream', () => {
101104
it('should be true when loading', async () => {
102105
streamController.enqueue('{"content": ');
103106

104-
userEvent.click(screen.getByTestId('submit-button'));
107+
await userEvent.click(screen.getByTestId('submit-button'));
105108

106109
// wait for element "loading" to have text content "true":
107110
await waitFor(() => {
@@ -112,9 +115,72 @@ describe('text stream', () => {
112115
streamController.close();
113116

114117
// wait for element "loading" to have text content "false":
118+
await waitFor(() => {
119+
expect(screen.getByTestId('loading')).toHaveTextContent('false');
120+
});
121+
});
122+
});
123+
124+
describe('stop', async () => {
125+
let streamController: ReadableStreamDefaultController<string>;
126+
let server: SetupServer;
127+
128+
beforeEach(() => {
129+
const stream = new ReadableStream({
130+
start(controller) {
131+
streamController = controller;
132+
},
133+
});
134+
135+
server = setupServer(
136+
http.post('/api/use-object', ({ request }) => {
137+
return new HttpResponse(stream.pipeThrough(new TextEncoderStream()), {
138+
status: 200,
139+
headers: {
140+
'Content-Type': 'text/event-stream',
141+
'Cache-Control': 'no-cache',
142+
Connection: 'keep-alive',
143+
},
144+
});
145+
}),
146+
);
147+
148+
server.listen();
149+
});
150+
151+
afterEach(() => {
152+
server.close();
153+
});
154+
155+
it('should be true when loading', async () => {
156+
streamController.enqueue('{"content": "h');
157+
158+
userEvent.click(screen.getByTestId('submit-button'));
159+
160+
// wait for element "loading" and "object" to have text content:
115161
await waitFor(() => {
116162
expect(screen.getByTestId('loading')).toHaveTextContent('true');
117163
});
164+
await waitFor(() => {
165+
expect(screen.getByTestId('object')).toHaveTextContent(
166+
'{"content":"h"}',
167+
);
168+
});
169+
170+
// click stop button:
171+
await userEvent.click(screen.getByTestId('stop-button'));
172+
173+
// wait for element "loading" to have text content "false":
174+
await waitFor(() => {
175+
expect(screen.getByTestId('loading')).toHaveTextContent('false');
176+
});
177+
178+
// this should not be consumed any more:
179+
streamController.enqueue('ello, world!"}');
180+
streamController.close();
181+
182+
// should only show start of object:
183+
expect(screen.getByTestId('object')).toHaveTextContent('{"content":"h"}');
118184
});
119185
});
120186

‎pnpm-lock.yaml

+17-12
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)
Please sign in to comment.