ai icon indicating copy to clipboard operation
ai copied to clipboard

ai/rsc components streamed through createStreamableUI are mounted multiple times when updating the ui node (update/done)

Open marcoripa96 opened this issue 1 year ago • 5 comments

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

marcoripa96 avatar Mar 31 '24 16:03 marcoripa96

Yes this is something related to Suspense and render(). Thanks for reporting!

shuding avatar Mar 31 '24 22:03 shuding

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

marcoripa96 avatar Apr 01 '24 09:04 marcoripa96

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();
   })();

unstubbable avatar Apr 10 '24 11:04 unstubbable

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

marcoripa96 avatar Apr 15 '24 10:04 marcoripa96

this is a pretty big issue when using client components also might be related https://github.com/vercel/ai/pull/1825

andrewdoro avatar Jul 03 '24 14:07 andrewdoro