applyToRun shallow-copies parts with [...seeded.parts], then mutates via lastTextPart.text += delta. The part objects are shared references with the previous state. React 18 double-invokes setState updaters in StrictMode/hydrateRoot (dev mode), so each delta is applied twice → doubled text.
Affected: Next.js (StrictMode on by default), TanStack Start, Remix — any SSR framework or <StrictMode> app.
Repro: Wrap any app using useAgentToolEvents in <React.StrictMode> and stream text-delta events. Every word appears twice.
Fix (one line in applyToRun):
- const parts = [...seeded.parts];
+ const parts = seeded.parts.map(p => ({...p}));
Deep-cloning each part object prevents the mutation from leaking across React's double-invocations.
Verified by running the same agent backend with a plain Vite SPA (clean ✅) vs the same SPA wrapped in <StrictMode> (doubled ❌). Applying the fix resolves it in all cases.
applyToRunshallow-copiespartswith[...seeded.parts], then mutates vialastTextPart.text += delta. The part objects are shared references with the previous state. React 18 double-invokessetStateupdaters in StrictMode/hydrateRoot(dev mode), so each delta is applied twice → doubled text.Affected: Next.js (StrictMode on by default), TanStack Start, Remix — any SSR framework or
<StrictMode>app.Repro: Wrap any app using
useAgentToolEventsin<React.StrictMode>and streamtext-deltaevents. Every word appears twice.Fix (one line in
applyToRun):Deep-cloning each part object prevents the mutation from leaking across React's double-invocations.
Verified by running the same agent backend with a plain Vite SPA (clean ✅) vs the same SPA wrapped in
<StrictMode>(doubled ❌). Applying the fix resolves it in all cases.