Skip to content

Commit 9348f06

Browse files
shudingMaxLeiter
andauthoredMar 5, 2024··
Add warning when streamables are not closed for a while during development (#1105)
Co-authored-by: Max Leiter <max.leiter@vercel.com>
1 parent 08a5308 commit 9348f06

File tree

4 files changed

+79
-14
lines changed

4 files changed

+79
-14
lines changed
 

Diff for: ‎.changeset/good-masks-suffer.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'ai': patch
3+
---
4+
5+
ai/rsc: improve dev error and warnings by trying to detect hanging streams

Diff for: ‎packages/core/rsc/constants.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export const STREAMABLE_VALUE_TYPE = Symbol.for('ui.streamable.value');
2+
export const DEV_DEFAULT_STREAMABLE_WARNING_TIME = 15 * 1000;

Diff for: ‎packages/core/rsc/streamable.tsx

+62-14
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ import zodToJsonSchema from 'zod-to-json-schema';
66
// TODO: This needs to be externalized.
77
import { OpenAIStream } from '../streams';
88

9-
import { STREAMABLE_VALUE_TYPE } from './constants';
9+
import {
10+
STREAMABLE_VALUE_TYPE,
11+
DEV_DEFAULT_STREAMABLE_WARNING_TIME,
12+
} from './constants';
1013
import {
1114
createResolvablePromise,
1215
createSuspensedChunk,
@@ -22,26 +25,43 @@ export function createStreamableUI(initialValue?: React.ReactNode) {
2225
let closed = false;
2326
let { row, resolve, reject } = createSuspensedChunk(initialValue);
2427

25-
function assertStream() {
28+
function assertStream(method: string) {
2629
if (closed) {
27-
throw new Error('UI stream is already closed.');
30+
throw new Error(method + ': UI stream is already closed.');
2831
}
2932
}
3033

34+
let warningTimeout: NodeJS.Timeout | undefined;
35+
function warnUnclosedStream() {
36+
if (process.env.NODE_ENV === 'development') {
37+
if (warningTimeout) {
38+
clearTimeout(warningTimeout);
39+
}
40+
warningTimeout = setTimeout(() => {
41+
console.warn(
42+
'The streamable UI has been slow to update. This may be a bug or a performance issue or you forgot to call `.done()`.',
43+
);
44+
}, DEV_DEFAULT_STREAMABLE_WARNING_TIME);
45+
}
46+
}
47+
warnUnclosedStream();
48+
3149
return {
3250
value: row,
3351
update(value: React.ReactNode) {
34-
assertStream();
52+
assertStream('.update()');
3553

3654
const resolvable = createResolvablePromise();
3755
currentValue = value;
3856

3957
resolve({ value: currentValue, done: false, next: resolvable.promise });
4058
resolve = resolvable.resolve;
4159
reject = resolvable.reject;
60+
61+
warnUnclosedStream();
4262
},
4363
append(value: React.ReactNode) {
44-
assertStream();
64+
assertStream('.append()');
4565

4666
const resolvable = createResolvablePromise();
4767
if (typeof currentValue === 'string' && typeof value === 'string') {
@@ -58,16 +78,24 @@ export function createStreamableUI(initialValue?: React.ReactNode) {
5878
resolve({ value: currentValue, done: false, next: resolvable.promise });
5979
resolve = resolvable.resolve;
6080
reject = resolvable.reject;
81+
82+
warnUnclosedStream();
6183
},
6284
error(error: any) {
63-
assertStream();
85+
assertStream('.error()');
6486

87+
if (warningTimeout) {
88+
clearTimeout(warningTimeout);
89+
}
6590
closed = true;
6691
reject(error);
6792
},
6893
done(...args: any) {
69-
assertStream();
94+
assertStream('.done()');
7095

96+
if (warningTimeout) {
97+
clearTimeout(warningTimeout);
98+
}
7199
closed = true;
72100
if (args.length) {
73101
resolve({ value: args[0], done: true });
@@ -83,15 +111,29 @@ export function createStreamableUI(initialValue?: React.ReactNode) {
83111
* On the client side, the value can be accessed via the useStreamableValue() hook.
84112
*/
85113
export function createStreamableValue<T = any>(initialValue?: T) {
86-
// let currentValue = initialValue
87114
let closed = false;
88115
let { promise, resolve, reject } = createResolvablePromise();
89116

90-
function assertStream() {
117+
function assertStream(method: string) {
91118
if (closed) {
92-
throw new Error('Value stream is already closed.');
119+
throw new Error(method + ': Value stream is already closed.');
120+
}
121+
}
122+
123+
let warningTimeout: NodeJS.Timeout | undefined;
124+
function warnUnclosedStream() {
125+
if (process.env.NODE_ENV === 'development') {
126+
if (warningTimeout) {
127+
clearTimeout(warningTimeout);
128+
}
129+
warningTimeout = setTimeout(() => {
130+
console.warn(
131+
'The streamable UI has been slow to update. This may be a bug or a performance issue or you forgot to call `.done()`.',
132+
);
133+
}, DEV_DEFAULT_STREAMABLE_WARNING_TIME);
93134
}
94135
}
136+
warnUnclosedStream();
95137

96138
function createWrapped(val: T | undefined, initial?: boolean) {
97139
if (initial) {
@@ -111,7 +153,7 @@ export function createStreamableValue<T = any>(initialValue?: T) {
111153
return {
112154
value: createWrapped(initialValue, true),
113155
update(value: T) {
114-
assertStream();
156+
assertStream('.update()');
115157

116158
const resolvePrevious = resolve;
117159
const resolvable = createResolvablePromise();
@@ -121,17 +163,23 @@ export function createStreamableValue<T = any>(initialValue?: T) {
121163

122164
resolvePrevious(createWrapped(value));
123165

124-
// currentValue = value
166+
warnUnclosedStream();
125167
},
126168
error(error: any) {
127-
assertStream();
169+
assertStream('.error()');
128170

171+
if (warningTimeout) {
172+
clearTimeout(warningTimeout);
173+
}
129174
closed = true;
130175
reject(error);
131176
},
132177
done(...args: any) {
133-
assertStream();
178+
assertStream('.done()');
134179

180+
if (warningTimeout) {
181+
clearTimeout(warningTimeout);
182+
}
135183
closed = true;
136184

137185
if (args.length) {

Diff for: ‎packages/core/rsc/streamable.ui.test.tsx

+11
Original file line numberDiff line numberDiff line change
@@ -319,4 +319,15 @@ describe('rsc - createStreamableUI()', () => {
319319
);
320320
expect(final).toMatchInlineSnapshot('"hello world!"');
321321
});
322+
323+
it('should error when updating a closed streamable', async () => {
324+
const ui = createStreamableUI(<div>1</div>);
325+
ui.done(<div>2</div>);
326+
327+
expect(() => {
328+
ui.update(<div>3</div>);
329+
}).toThrowErrorMatchingInlineSnapshot(
330+
'".update(): UI stream is already closed."',
331+
);
332+
});
322333
});

0 commit comments

Comments
 (0)
Please sign in to comment.