ai
ai copied to clipboard
ai/rsc components streamed through createStreamableUI are mounted multiple times when updating the ui node (update/done)
Description
I was reading through the https://chat.vercel.ai/ github example and in particular I found this useStreamableText hook. I report the implementation here for clarity:
import { StreamableValue, readStreamableValue } from 'ai/rsc'
import { useEffect, useState } from 'react'
export const useStreamableText = (
content: string | StreamableValue<string>
) => {
const [rawContent, setRawContent] = useState(
typeof content === 'string' ? content : ''
)
useEffect(() => {
(async () => {
if (typeof content === 'object') {
let value = ''
for await (const delta of readStreamableValue(content)) {
console.log(delta)
if (typeof delta === 'string') {
setRawContent((value = value + delta))
}
}
}
})()
}, [content])
return rawContent
}
When I used that hook in my project like in the following example:
export function AssistantMessage({
content,
}: {
content: string | StreamableValue<string>;
}) {
const text = useStreamableText(content);
return (
<div
style={{ wordBreak: "break-word" }}
>
{text}
</div>
);
}
I noticed the UI flashing during the last part of the stream. I checked directly on https://chat.vercel.ai/ and I noticed the same behaviour.
I tried printing to console rawContent and I noticed that just before the last delta received, rawContent becomes an empty string "".
import { StreamableValue, readStreamableValue } from 'ai/rsc'
import { useEffect, useState } from 'react'
export const useStreamableText = (
content: string | StreamableValue<string>
) => {
const [rawContent, setRawContent] = useState(
typeof content === 'string' ? content : ''
)
// before the last delta I get "", it should be impossibile
console.log({ rawContent });
useEffect(() => {
(async () => {
if (typeof content === 'object') {
let value = ''
for await (const delta of readStreamableValue(content)) {
console.log(delta)
if (typeof delta === 'string') {
setRawContent((value = value + delta))
}
}
}
})()
}, [content])
return rawContent
}
I ended up figuring out that the actual <AssistantMessage /> component is mounting mutiple times and it happens whenever the ui node is updated or when the done function is called and causing that flashing issue in my code. I made a reproduction here. Also not sure why in the codesanbox preview isn't really noticeable, but if you try looking at the preview in a new tab like here, you can see the issue more clearly.
This happens on:
- next: 14.2.0-canary.48
- ai: 3.0.16
Yes this is something related to Suspense and render(). Thanks for reporting!
Hi! Thanks for the clarification! I just wanted to point out that I am not using the render(). This is also happening using the primitives createStreamableUI and createStreamableValue
In the posted example you can work around the mentioned suspense issue (i.e. the whole promise chain is replayed when the fallback is replaced with the children) by skipping the ui.update() step:
(async () => {
await sleep(50);
const textNode = createStreamableValue("");
- ui.update(<AssistantMessageWithStreamableValue content={textNode.value} />);
+ ui.done(<AssistantMessageWithStreamableValue content={textNode.value} />);
for await (const token of generateTokens(text)) {
textNode.update(token);
}
textNode.done();
- ui.done();
})();
I'm currently having a hard time working around this issue. I'm currently developing a shopping assistant for a work project. I want to provide some feedback in the UI when some products are added to the cart. Here I post an example of the code:
completion.onEvent("add_products_to_cart", async (data) => {
let streamableCart:
| ReturnType<typeof createStreamableValue<Product>>
| undefined;
if (data.state === "START" || !streamableCart) {
streamableCart = createStreamableValue<Product>();
} else {
const { products } = data;
const product = products[Object.keys(products)[0]][0];
if (product) {
ui.update(
<>
<EventMessage>
{Object.keys(products).length} prodotti aggiunti al carrello
</EventMessage>
<AddToCart products={streamableCart.value} />
</>,
);
streamableCart.done(product);
}
}
});
And for the AddToCart component:
export function AddToCart({
products,
}: {
products: StreamableValue<Product>;
}) {
const { addToCart } = useShoppingCartActions();
useEffect(() => {
(async () => {
let productsToAdd: Product[] = [];
for await (const product of readStreamableValue(products)) {
if (product) {
productsToAdd.push(product);
}
}
if (productsToAdd.length > 0) {
console.log({ productsToAdd });
addToCart(productsToAdd);
}
})();
}, [products]);
return null;
}
The problem I have is that products are added multiple times because the component is mounted multiple times. I haven't found a way to get around this and it's kinda blocking us
this is a pretty big issue when using client components also might be related https://github.com/vercel/ai/pull/1825