Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
marcoripa96 opened this issue Mar 31, 2024 · 5 comments
Assignees
Labels
bug Something isn't working

Comments

@marcoripa96
Copy link

marcoripa96 commented Mar 31, 2024

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 marcoripa96 changed the title ai/rsc components streamed through createStreamableUI are mounted twice ai/rsc components streamed through createStreamableUI are mounted multiple times when updating the ui node (update/done) Mar 31, 2024
@shuding shuding self-assigned this Mar 31, 2024
@shuding shuding added the bug Something isn't working label Mar 31, 2024
@shuding
Copy link
Member

shuding commented Mar 31, 2024

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

@marcoripa96
Copy link
Author

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

@unstubbable
Copy link
Contributor

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

@marcoripa96
Copy link
Author

marcoripa96 commented Apr 15, 2024

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

@andrewdoro
Copy link

this is a pretty big issue when using client components
also might be related #1825

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working
Projects
None yet
Development

No branches or pull requests

4 participants