Skip to content

Commit f35826c

Browse files
rpviloclaude
andauthored
perf: lazy-load Motion and fix React Doctor findings (CHAT-46) (#91)
Motion's animation engine shipped in every route's first load. The app now uses `m.*` from motion/react-m inside one strict LazyMotion provider that loads domMax on demand from lib/motion-features.ts, cutting ~24 kB gzipped from the playground and docs first-load JS. Strict mode makes a stray `motion.*` throw instead of quietly pulling the engine back in, and MotionConfig reducedMotion="user" snaps the remaining height reveals. Fixes from a React Doctor review (23 errors / 66 warnings -> 18 / 57; the rest are triaged as intentional or false positives): - Refs are no longer written during render where React may discard it. AskUser mirrors its latest highlight and register callback in insertion effects, which run before the options' own layout effects that seed the highlight. Composer.Command's closing flag and the playground's ask-user panel cache are state that changes only when they flip. - AskUser, the shared collapsible (Reasoning, Steps), the anchored Tabs surface and SidebarViews memoize their context values. - The playground builds its seen-messages set once instead of per stream chunk, the steps chevron transitions only opacity and rotate, and the docs search input gets an accessible name. - The playground mints its chat id with a lazy useState initializer: useMemo may recompute, which would swap the chat out from under the user. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent b4d9ec6 commit f35826c

21 files changed

Lines changed: 166 additions & 105 deletions

‎.changeset/react-doctor-fixes.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@intentface/chat": patch
3+
---
4+
5+
Render-safety and re-render fixes from a React Doctor review.
6+
7+
**No ref writes during render.** `AskUser.Options` and `AskUser.Option` mirror their latest values into refs in an insertion effect instead of during render, which React may discard. It runs before every layout effect, so options still seed the initial highlight from their own. `Composer.Command`'s "this prefix is the one closing" flag is now state that flips only at open and close.
8+
9+
**Stable context values.** `AskUser.Root`, `AskUser.Options`, `AskUser.Option`, the shared collapsible behind `Reasoning` and `Steps`, and the anchored `Tabs` surface memoize their context values, so memoized consumers no longer redraw on every parent render.

‎app/(chat)/playground/page.tsx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
"use client";
22

3-
import { useMemo } from "react";
3+
import { useState } from "react";
44
import { Chat } from "@/components/chat";
55

66
export default function Home() {
7-
const chatId = useMemo(() => crypto.randomUUID(), []);
7+
// A lazy state initializer mints the id exactly once per mount; useMemo may
8+
// recompute it, which would swap the chat instance out from under the user.
9+
const [chatId] = useState(() => crypto.randomUUID());
810

911
return <Chat chatId={chatId} />;
1012
}

‎components/ai/attachments.tsx‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
import type { AttachmentErrorCode, AttachmentItem } from "@intentface/chat/attachments";
44
import { File, Paperclip, X } from "@keyline-icons/react";
5-
import { AnimatePresence, motion } from "motion/react";
5+
import { AnimatePresence } from "motion/react";
6+
import * as m from "motion/react-m";
67
import Image from "next/image";
78
import type { ComponentProps, ReactNode } from "react";
89
import { useEffect, useState } from "react";
@@ -41,7 +42,7 @@ type AttachmentsRootProps = {
4142
const AttachmentsRoot = ({ children, className, show = true }: AttachmentsRootProps) => (
4243
<AnimatePresence initial={false}>
4344
{show && (
44-
<motion.div
45+
<m.div
4546
initial={{ height: 0, opacity: 0 }}
4647
animate={{ height: "auto", opacity: 1 }}
4748
exit={{ height: 0, opacity: 0 }}
@@ -51,7 +52,7 @@ const AttachmentsRoot = ({ children, className, show = true }: AttachmentsRootPr
5152
<div data-slot="attachments" className={cn("flex flex-wrap gap-2 px-2 pt-2", className)}>
5253
<AnimatePresence initial={false}>{children}</AnimatePresence>
5354
</div>
54-
</motion.div>
55+
</m.div>
5556
)}
5657
</AnimatePresence>
5758
);
@@ -73,7 +74,7 @@ const AttachmentsItem = ({ item, children, className }: AttachmentsItemProps) =>
7374
const filename = item.filename ?? "Attachment";
7475
const Icon = getFileIcon(mediaType);
7576
return (
76-
<motion.div
77+
<m.div
7778
layout
7879
initial={{ opacity: 0, scale: 0.8 }}
7980
animate={{ opacity: 1, scale: 1 }}
@@ -115,7 +116,7 @@ const AttachmentsItem = ({ item, children, className }: AttachmentsItemProps) =>
115116
)}
116117
</div>
117118
{children}
118-
</motion.div>
119+
</m.div>
119120
);
120121
};
121122

@@ -172,7 +173,7 @@ const AttachmentsDropzone = ({
172173
const content = (
173174
<AnimatePresence>
174175
{show && (
175-
<motion.div
176+
<m.div
176177
initial={{ opacity: 0 }}
177178
animate={{ opacity: 1 }}
178179
exit={{ opacity: 0 }}
@@ -181,7 +182,7 @@ const AttachmentsDropzone = ({
181182
className={cn(dropzoneVariants[variant], className)}
182183
>
183184
{children ?? <span className="text-sm font-medium">Drop files here</span>}
184-
</motion.div>
185+
</m.div>
185186
)}
186187
</AnimatePresence>
187188
);

‎components/ai/composer.tsx‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ import {
1616
useComposerStore,
1717
useComposerSubmit,
1818
} from "@intentface/chat/composer";
19-
import { AnimatePresence, motion } from "motion/react";
19+
import { AnimatePresence } from "motion/react";
20+
import * as m from "motion/react-m";
2021
import { Children, type ComponentProps, type ReactNode, useMemo, useRef } from "react";
2122
import { AskUser } from "@/components/ai/ask-user";
2223
import { Attachments } from "@/components/ai/attachments";
@@ -129,7 +130,7 @@ const ComposerAttachments = ({
129130
>
130131
<AnimatePresence initial={false}>
131132
{(attachments.isDragging || attachments.items.length > 0) && (
132-
<motion.div
133+
<m.div
133134
initial={{ height: 0, opacity: 0 }}
134135
animate={{ height: "auto", opacity: 1 }}
135136
exit={{ height: 0, opacity: 0 }}
@@ -155,7 +156,7 @@ const ComposerAttachments = ({
155156
)}
156157
<Attachments.Dropzone visible={attachments.isDragging} variant="inline" />
157158
</div>
158-
</motion.div>
159+
</m.div>
159160
)}
160161
</AnimatePresence>
161162
<Attachments.Error code={attachments.error} />
@@ -255,7 +256,7 @@ const ComposerPlaceholder = ({ placeholder, children, className }: ComposerPlace
255256
return (
256257
<div className="pointer-events-none flex min-h-lh">
257258
<AnimatePresence mode="popLayout" initial={false}>
258-
<motion.span
259+
<m.span
259260
key={key}
260261
initial={{ opacity: 0, y: "100%", filter: "blur(4px)" }}
261262
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
@@ -264,7 +265,7 @@ const ComposerPlaceholder = ({ placeholder, children, className }: ComposerPlace
264265
className={cn("text-ink-tertiary font-book leading-6", className)}
265266
>
266267
{typeof items[0] === "string" ? currentItem : items[key % items.length]}
267-
</motion.span>
268+
</m.span>
268269
</AnimatePresence>
269270
</div>
270271
);
@@ -331,7 +332,7 @@ const ComposerSubmit = ({
331332
{...props}
332333
>
333334
<AnimatePresence mode="wait" initial={false}>
334-
<motion.span
335+
<m.span
335336
key={isGenerating ? "stop" : "send"}
336337
initial={{ opacity: 0, scale: 0.8, filter: "blur(4px)" }}
337338
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
@@ -340,7 +341,7 @@ const ComposerSubmit = ({
340341
className="flex items-center justify-center"
341342
>
342343
{isGenerating ? <Stop /> : (children ?? <ArrowUp />)}
343-
</motion.span>
344+
</m.span>
344345
</AnimatePresence>
345346
</IconButton>
346347
);

‎components/ai/message.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
useMessageSelectionScope,
99
} from "@intentface/chat/message";
1010
import { Check, Copy, File, MessageSquare, Paperclip, Stop } from "@keyline-icons/react";
11-
import { motion } from "motion/react";
11+
import * as m from "motion/react-m";
1212
import Image from "next/image";
1313
import { type ComponentProps, useMemo } from "react";
1414
import { Chip } from "@/components/ai/chip";
@@ -29,7 +29,7 @@ type MessageRootProps = ComponentProps<typeof MessagePrimitive.Root>;
2929
const MessageRoot = ({ className, ...props }: MessageRootProps) => (
3030
<MessagePrimitive.Root
3131
render={
32-
<motion.div
32+
<m.div
3333
initial={{ opacity: 0 }}
3434
animate={{ opacity: 1 }}
3535
exit={{ opacity: 0 }}

‎components/ai/reasoning.tsx‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
import { Reasoning as ReasoningPrimitive, useReasoning } from "@intentface/chat/reasoning";
44
import { Brain, ChevronDown } from "@keyline-icons/react";
5-
import { AnimatePresence, motion } from "motion/react";
5+
import { AnimatePresence } from "motion/react";
6+
import * as m from "motion/react-m";
67
import { type ComponentProps, memo, type ReactNode } from "react";
78
import { Markdown } from "@/components/ui/markdown";
89
import { splitReasoningByHeaders } from "@/lib/ai/message-info";
@@ -77,7 +78,7 @@ const ReasoningTrigger = memo(
7778
<div className="flex items-center gap-1.5">
7879
<Brain className="size-[15px]" />
7980
<AnimatePresence mode="popLayout" initial={false}>
80-
<motion.span
81+
<m.span
8182
key={key}
8283
initial={{ opacity: 0, y: "100%", filter: "blur(4px)" }}
8384
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
@@ -86,7 +87,7 @@ const ReasoningTrigger = memo(
8687
className="whitespace-nowrap"
8788
>
8889
{component}
89-
</motion.span>
90+
</m.span>
9091
</AnimatePresence>
9192
</div>
9293
<ChevronDown

‎components/ai/thread.tsx‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import {
88
useThreadVisibility,
99
} from "@intentface/chat/thread";
1010
import { ArrowDown } from "@keyline-icons/react";
11-
import { AnimatePresence, motion } from "motion/react";
11+
import { AnimatePresence } from "motion/react";
12+
import * as m from "motion/react-m";
1213
import type { ComponentProps } from "react";
1314
import { memo, useCallback } from "react";
1415
import Button from "@/components/ui/button";
@@ -121,7 +122,7 @@ const ThreadComposer = ({ className, children, ...props }: ThreadComposerProps)
121122
// package's useThread hook so the motion enter/exit stays exactly as designed.
122123
// ---------------------------------------------------------------------------
123124

124-
export type ThreadScrollButtonProps = ComponentProps<typeof motion.div>;
125+
export type ThreadScrollButtonProps = ComponentProps<typeof m.div>;
125126

126127
const ThreadScrollButton = ({ className, ...props }: ThreadScrollButtonProps) => {
127128
const { isAtBottom, scrollToBottom } = useThread();
@@ -135,7 +136,7 @@ const ThreadScrollButton = ({ className, ...props }: ThreadScrollButtonProps) =>
135136
<div className="z-2 flex h-0 w-full max-w-(--thread-width) items-end justify-center">
136137
<AnimatePresence>
137138
{!isAtBottom && (
138-
<motion.div
139+
<m.div
139140
initial={{ opacity: 0, y: 8, scale: 0.9 }}
140141
animate={{ opacity: 1, y: 0, scale: 1 }}
141142
exit={{ opacity: 0, y: 8, scale: 0.9 }}
@@ -146,7 +147,7 @@ const ThreadScrollButton = ({ className, ...props }: ThreadScrollButtonProps) =>
146147
<ArrowDown />
147148
Latest
148149
</Button>
149-
</motion.div>
150+
</m.div>
150151
)}
151152
</AnimatePresence>
152153
</div>

‎components/chat.tsx‎

Lines changed: 26 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ import {
2323
// Keyline has no sandbox icon, so this one stays on Tabler.
2424
import { IconSandbox } from "@tabler/icons-react";
2525
import type { ChatStatus } from "ai";
26-
import { AnimatePresence, motion, stagger } from "motion/react";
26+
import { AnimatePresence, stagger } from "motion/react";
27+
import * as m from "motion/react-m";
2728
import { useRouter } from "next/navigation";
2829
import {
2930
Children,
@@ -188,7 +189,7 @@ const TimelineStep = ({
188189
<span className="transition-opacity group-hover/steps-trigger:opacity-0 group-data-open/steps-trigger:opacity-0">
189190
<Icon className={cn("size-3.5", status === "active" && "animate-pulse")} />
190191
</span>
191-
<ChevronDown className="absolute size-4 opacity-0 transition-all group-hover/steps-trigger:opacity-100 group-data-open/steps-trigger:rotate-180 group-data-open/steps-trigger:opacity-100" />
192+
<ChevronDown className="absolute size-4 opacity-0 transition-[opacity,rotate] group-hover/steps-trigger:opacity-100 group-data-open/steps-trigger:rotate-180 group-data-open/steps-trigger:opacity-100" />
192193
</span>
193194
<span className={labelClasses}>{label}</span>
194195
</Steps.Trigger>
@@ -332,7 +333,7 @@ const InterleavedSteps = ({
332333
// (roll matches the composer placeholder / TextLoop; shimmer only while active).
333334
const header = (
334335
<AnimatePresence mode="popLayout" initial={false}>
335-
<motion.span
336+
<m.span
336337
key={displayLabel}
337338
initial={{ opacity: 0, y: "100%", filter: "blur(4px)" }}
338339
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
@@ -341,7 +342,7 @@ const InterleavedSteps = ({
341342
className="block"
342343
>
343344
{isActive ? <TextShimmer>{displayLabel}</TextShimmer> : displayLabel}
344-
</motion.span>
345+
</m.span>
345346
</AnimatePresence>
346347
);
347348

@@ -564,8 +565,9 @@ const ChatMessages = () => {
564565
const isError = status === "error";
565566
const isStreaming = status === "streaming";
566567

567-
// Track messages present at mount — skip entrance animation for these
568-
const initialMessageIds = useRef(new Set(messages.map((m) => m.id)));
568+
// Track messages present at mount — skip entrance animation for these. Lazy:
569+
// this component renders per stream chunk, and the set is only built once.
570+
const [initialMessageIds] = useState(() => new Set(messages.map((message) => message.id)));
569571

570572
const turns = groupTurns(messages);
571573
const lastMessageId = messages.at(-1)?.id;
@@ -581,7 +583,7 @@ const ChatMessages = () => {
581583
isLast={message.id === lastMessageId}
582584
isStreaming={message.id === lastMessageId && isStreaming}
583585
isError={isError}
584-
skipAnimation={initialMessageIds.current.has(message.id)}
586+
skipAnimation={initialMessageIds.has(message.id)}
585587
/>
586588
))}
587589
{turnIndex === turns.length - 1 && isError && (
@@ -667,11 +669,9 @@ const useAskUserPanelState = (
667669
messages: readonly AppUIMessage[],
668670
status: ChatStatus,
669671
): AskUserPanelState | null => {
670-
const prevRef = useRef<AskUserPanelState | null>(null);
671-
672672
const lastAssistant =
673673
status === "ready" || status === "streaming"
674-
? messages.findLast((m) => m.role === "assistant")
674+
? messages.findLast((message) => message.role === "assistant")
675675
: undefined;
676676
// Widen to the package's structural part contract so the guard can narrow.
677677
const parts: readonly UnknownPart[] = lastAssistant?.parts ?? [];
@@ -688,14 +688,15 @@ const useAskUserPanelState = (
688688
}
689689
: null;
690690

691-
if (
692-
prevRef.current?.toolCallId === next?.toolCallId &&
693-
(prevRef.current === null) === (next === null)
694-
) {
695-
return prevRef.current;
691+
// Keep the first object seen for a tool call, so the result stays
692+
// referentially stable while it waits. State adjusted during render rather
693+
// than a ref written in it: it changes only when the tool call does.
694+
const [current, setCurrent] = useState(next);
695+
if (current?.toolCallId !== next?.toolCallId) {
696+
setCurrent(next);
697+
return next;
696698
}
697-
prevRef.current = next;
698-
return next;
699+
return current;
699700
};
700701

701702
// Thin bridge — the only composer piece that re-renders per stream chunk. It derives
@@ -1041,7 +1042,7 @@ const ChatPlaceholder = () => {
10411042

10421043
return (
10431044
<AnimatePresence>
1044-
<motion.div
1045+
<m.div
10451046
variants={variants}
10461047
initial="hidden"
10471048
animate="visible"
@@ -1053,21 +1054,21 @@ const ChatPlaceholder = () => {
10531054
}}
10541055
className="flex flex-col items-center justify-center gap-4"
10551056
>
1056-
<motion.div variants={variants}>
1057+
<m.div variants={variants}>
10571058
<LogoTile size="lg" />
1058-
</motion.div>
1059+
</m.div>
10591060
<div className="flex flex-col items-center justify-center">
1060-
<motion.span
1061+
<m.span
10611062
variants={variants}
10621063
className="font-semibold text-[20px] text-ink-primary leading-7 tracking-[-0.01em]"
10631064
>
10641065
Intentface Chat
1065-
</motion.span>
1066-
<motion.span variants={variants} className="text-ink-secondary text-md">
1066+
</m.span>
1067+
<m.span variants={variants} className="text-ink-secondary text-md">
10671068
Start a conversation
1068-
</motion.span>
1069+
</m.span>
10691070
</div>
1070-
</motion.div>
1071+
</m.div>
10711072
</AnimatePresence>
10721073
);
10731074
};

0 commit comments

Comments
 (0)