Skip to content

useObjectChat rebuilds its DefaultChatTransport on every render — the memo's body dep is an inline literal every caller recreates (finding, dormant) #4187

Description

@yinlianghui

Observation-class. Nothing a user hits today; filing so it is on the record rather than rediscovered.

packages/plugin-chatbot/src/useObjectChat.ts memoizes the transport:

const transport = useMemo(() => {
  if (!isApiMode) return undefined;
  return new DefaultChatTransport({ api: api!, ... });
}, [isApiMode, api, headers, body, model, systemPrompt, streamingEnabled, conversationId]);

body and headers are object props, and every caller passes a fresh literal on each render. AiChatPage's ChatPane is the clearest case:

body: {
  context: {
    activeApp: 'AI',
    agentName: activeAgent,
    autoPublishAiBuilds: getRuntimeConfig().features.autoPublishAiBuilds,
    ...(editPackageId ? { packageId: editPackageId } : {}),
  },
},

That object has a new identity on every render, so the memo never hits and a DefaultChatTransport is constructed on every render of every chat surface — during a streaming turn, that is once per token batch.

Why it is dormant rather than broken

@ai-sdk/react@4.0.59's useChat does not rebuild its Chat when the transport identity changes. It stores the latest transport in a ref and its own chatOptions.transport delegates through getTransport():

latestRef.current = { ..., transport: options.transport };
const chatOptions = { ..., transport: { sendMessages: (o) => getTransport().sendMessages(o), ... } };
const shouldRecreateChat = "chat" in options && options.chat !== chatRef.current
  || "id" in options && options.id != null && chatRef.current.id !== options.id;

useObjectChat passes neither chat nor id, so the Chat object — and with it the whole message list — survives. The wasted construction is the entire cost.

Why it is worth recording anyway

The safety here is entirely a property of the SDK's internals, not of this call site. The memo looks like it is protecting something, and the dep list reads as if a changing transport would be disruptive. An SDK upgrade that starts keying the Chat on the transport (or a future id being threaded through) would turn a silent inefficiency into a thread that resets on every render — and the memo would give a reviewer no warning, because it is already written as though it works.

Two candidate directions, neither obviously right without measuring:

  • memoize at the call sites (body / headers behind useMemo), which fixes it wherever it is done but is unenforceable and will re-rot;
  • drop body/headers from the dep list and read them through a ref inside prepareSendMessagesRequest, which is what the file already does for model (modelRef) and for the handoff id (parentConvRef) — the established idiom in this very hook, and the only one that cannot be undone by a new caller.

Found while working #2627 (the AI build thread's blank-out was a different mechanism entirely — a pane remount driven by useChatConversation clearing the conversation id; PR #4183). This was ruled out as its cause and is unrelated to it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions