From 79753c040c01b1532576be0cf196fcd47f116a4a Mon Sep 17 00:00:00 2001 From: Rasmus Viloma Date: Tue, 13 Oct 2026 00:02:38 +0300 Subject: [PATCH] perf: lazy-load Motion and fix React Doctor findings (CHAT-46) 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 --- .changeset/react-doctor-fixes.md | 9 ++++ app/(chat)/playground/page.tsx | 6 ++- components/ai/attachments.tsx | 15 +++--- components/ai/composer.tsx | 15 +++--- components/ai/message.tsx | 4 +- components/ai/reasoning.tsx | 7 +-- components/ai/thread.tsx | 9 ++-- components/chat.tsx | 51 +++++++++++---------- components/docs/component-preview-frame.tsx | 15 +++--- components/docs/docs-search.tsx | 1 + components/docs/props-table.tsx | 6 +-- components/playground-settings.tsx | 11 +++-- components/providers.tsx | 13 +++++- components/ui/progressive-blur.tsx | 5 +- components/ui/sidebar.tsx | 14 ++++-- components/ui/text-loop.tsx | 7 +-- lib/motion-features.ts | 6 +++ packages/chat/src/ask-user/ask-user.tsx | 49 ++++++++++++-------- packages/chat/src/composer/command-list.tsx | 13 ++++-- packages/chat/src/internal/collapsible.tsx | 4 +- packages/chat/src/tabs/anchored.tsx | 11 ++--- 21 files changed, 166 insertions(+), 105 deletions(-) create mode 100644 .changeset/react-doctor-fixes.md create mode 100644 lib/motion-features.ts diff --git a/.changeset/react-doctor-fixes.md b/.changeset/react-doctor-fixes.md new file mode 100644 index 0000000..a360426 --- /dev/null +++ b/.changeset/react-doctor-fixes.md @@ -0,0 +1,9 @@ +--- +"@intentface/chat": patch +--- + +Render-safety and re-render fixes from a React Doctor review. + +**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. + +**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. diff --git a/app/(chat)/playground/page.tsx b/app/(chat)/playground/page.tsx index 2e5a590..50d9712 100644 --- a/app/(chat)/playground/page.tsx +++ b/app/(chat)/playground/page.tsx @@ -1,10 +1,12 @@ "use client"; -import { useMemo } from "react"; +import { useState } from "react"; import { Chat } from "@/components/chat"; export default function Home() { - const chatId = useMemo(() => crypto.randomUUID(), []); + // A lazy state initializer mints the id exactly once per mount; useMemo may + // recompute it, which would swap the chat instance out from under the user. + const [chatId] = useState(() => crypto.randomUUID()); return ; } diff --git a/components/ai/attachments.tsx b/components/ai/attachments.tsx index 0baf92b..fc250ab 100644 --- a/components/ai/attachments.tsx +++ b/components/ai/attachments.tsx @@ -2,7 +2,8 @@ import type { AttachmentErrorCode, AttachmentItem } from "@intentface/chat/attachments"; import { File, Paperclip, X } from "@keyline-icons/react"; -import { AnimatePresence, motion } from "motion/react"; +import { AnimatePresence } from "motion/react"; +import * as m from "motion/react-m"; import Image from "next/image"; import type { ComponentProps, ReactNode } from "react"; import { useEffect, useState } from "react"; @@ -41,7 +42,7 @@ type AttachmentsRootProps = { const AttachmentsRoot = ({ children, className, show = true }: AttachmentsRootProps) => ( {show && ( - {children} - + )} ); @@ -73,7 +74,7 @@ const AttachmentsItem = ({ item, children, className }: AttachmentsItemProps) => const filename = item.filename ?? "Attachment"; const Icon = getFileIcon(mediaType); return ( - )} {children} - + ); }; @@ -172,7 +173,7 @@ const AttachmentsDropzone = ({ const content = ( {show && ( - {children ?? Drop files here} - + )} ); diff --git a/components/ai/composer.tsx b/components/ai/composer.tsx index 4264a8a..eb24835 100644 --- a/components/ai/composer.tsx +++ b/components/ai/composer.tsx @@ -16,7 +16,8 @@ import { useComposerStore, useComposerSubmit, } from "@intentface/chat/composer"; -import { AnimatePresence, motion } from "motion/react"; +import { AnimatePresence } from "motion/react"; +import * as m from "motion/react-m"; import { Children, type ComponentProps, type ReactNode, useMemo, useRef } from "react"; import { AskUser } from "@/components/ai/ask-user"; import { Attachments } from "@/components/ai/attachments"; @@ -129,7 +130,7 @@ const ComposerAttachments = ({ > {(attachments.isDragging || attachments.items.length > 0) && ( - - + )} @@ -255,7 +256,7 @@ const ComposerPlaceholder = ({ placeholder, children, className }: ComposerPlace return (
- {typeof items[0] === "string" ? currentItem : items[key % items.length]} - +
); @@ -331,7 +332,7 @@ const ComposerSubmit = ({ {...props} > - {isGenerating ? : (children ?? )} - + ); diff --git a/components/ai/message.tsx b/components/ai/message.tsx index ee06b3a..2953a3a 100644 --- a/components/ai/message.tsx +++ b/components/ai/message.tsx @@ -8,7 +8,7 @@ import { useMessageSelectionScope, } from "@intentface/chat/message"; import { Check, Copy, File, MessageSquare, Paperclip, Stop } from "@keyline-icons/react"; -import { motion } from "motion/react"; +import * as m from "motion/react-m"; import Image from "next/image"; import { type ComponentProps, useMemo } from "react"; import { Chip } from "@/components/ai/chip"; @@ -29,7 +29,7 @@ type MessageRootProps = ComponentProps; const MessageRoot = ({ className, ...props }: MessageRootProps) => ( - {component} - + ; +export type ThreadScrollButtonProps = ComponentProps; const ThreadScrollButton = ({ className, ...props }: ThreadScrollButtonProps) => { const { isAtBottom, scrollToBottom } = useThread(); @@ -135,7 +136,7 @@ const ThreadScrollButton = ({ className, ...props }: ThreadScrollButtonProps) =>
{!isAtBottom && ( - Latest - + )}
diff --git a/components/chat.tsx b/components/chat.tsx index b255be4..f6936f2 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -23,7 +23,8 @@ import { // Keyline has no sandbox icon, so this one stays on Tabler. import { IconSandbox } from "@tabler/icons-react"; import type { ChatStatus } from "ai"; -import { AnimatePresence, motion, stagger } from "motion/react"; +import { AnimatePresence, stagger } from "motion/react"; +import * as m from "motion/react-m"; import { useRouter } from "next/navigation"; import { Children, @@ -188,7 +189,7 @@ const TimelineStep = ({ - + {label} @@ -332,7 +333,7 @@ const InterleavedSteps = ({ // (roll matches the composer placeholder / TextLoop; shimmer only while active). const header = ( - {isActive ? {displayLabel} : displayLabel} - + ); @@ -564,8 +565,9 @@ const ChatMessages = () => { const isError = status === "error"; const isStreaming = status === "streaming"; - // Track messages present at mount — skip entrance animation for these - const initialMessageIds = useRef(new Set(messages.map((m) => m.id))); + // Track messages present at mount — skip entrance animation for these. Lazy: + // this component renders per stream chunk, and the set is only built once. + const [initialMessageIds] = useState(() => new Set(messages.map((message) => message.id))); const turns = groupTurns(messages); const lastMessageId = messages.at(-1)?.id; @@ -581,7 +583,7 @@ const ChatMessages = () => { isLast={message.id === lastMessageId} isStreaming={message.id === lastMessageId && isStreaming} isError={isError} - skipAnimation={initialMessageIds.current.has(message.id)} + skipAnimation={initialMessageIds.has(message.id)} /> ))} {turnIndex === turns.length - 1 && isError && ( @@ -667,11 +669,9 @@ const useAskUserPanelState = ( messages: readonly AppUIMessage[], status: ChatStatus, ): AskUserPanelState | null => { - const prevRef = useRef(null); - const lastAssistant = status === "ready" || status === "streaming" - ? messages.findLast((m) => m.role === "assistant") + ? messages.findLast((message) => message.role === "assistant") : undefined; // Widen to the package's structural part contract so the guard can narrow. const parts: readonly UnknownPart[] = lastAssistant?.parts ?? []; @@ -688,14 +688,15 @@ const useAskUserPanelState = ( } : null; - if ( - prevRef.current?.toolCallId === next?.toolCallId && - (prevRef.current === null) === (next === null) - ) { - return prevRef.current; + // Keep the first object seen for a tool call, so the result stays + // referentially stable while it waits. State adjusted during render rather + // than a ref written in it: it changes only when the tool call does. + const [current, setCurrent] = useState(next); + if (current?.toolCallId !== next?.toolCallId) { + setCurrent(next); + return next; } - prevRef.current = next; - return next; + return current; }; // Thin bridge — the only composer piece that re-renders per stream chunk. It derives @@ -1041,7 +1042,7 @@ const ChatPlaceholder = () => { return ( - { }} className="flex flex-col items-center justify-center gap-4" > - + - +
- Intentface Chat - - + + Start a conversation - +
-
+
); }; diff --git a/components/docs/component-preview-frame.tsx b/components/docs/component-preview-frame.tsx index 9b4ebe9..2fbe8c3 100644 --- a/components/docs/component-preview-frame.tsx +++ b/components/docs/component-preview-frame.tsx @@ -1,7 +1,8 @@ "use client"; import { RotateCcw } from "@keyline-icons/react"; -import { motion, useReducedMotion } from "motion/react"; +import { useReducedMotion } from "motion/react"; +import * as m from "motion/react-m"; import { type ReactNode, useState } from "react"; import { IconButton } from "@/components/ui/icon-button"; import { useMeasure } from "@/hooks/use-measure"; @@ -65,7 +66,7 @@ export const ComponentPreviewFrame = ({ preview, code }: ComponentPreviewFramePr ))} {/* Clips only mid-switch, so a demo's own popups can spill out otherwise. */} - - {preview} - - + {code} - - + + ); }; diff --git a/components/docs/docs-search.tsx b/components/docs/docs-search.tsx index a8719b9..930923d 100644 --- a/components/docs/docs-search.tsx +++ b/components/docs/docs-search.tsx @@ -269,6 +269,7 @@ export const DocsSearch = ({ suggestions = [] }: { suggestions?: SearchSuggestio onChange={(event) => setSearch(event.target.value)} onKeyDown={handleKeyDown} placeholder="Search documentation…" + aria-label="Search documentation" role="combobox" aria-expanded={flat.length > 0} aria-controls={listId} diff --git a/components/docs/props-table.tsx b/components/docs/props-table.tsx index 5a9aa43..adf8757 100644 --- a/components/docs/props-table.tsx +++ b/components/docs/props-table.tsx @@ -1,7 +1,7 @@ "use client"; import { ChevronDown } from "@keyline-icons/react"; -import { motion } from "motion/react"; +import * as m from "motion/react-m"; import { type ReactNode, useState } from "react"; import { cn } from "@/lib/utils"; import { ReferenceTable } from "./reference-table"; @@ -87,7 +87,7 @@ const PropsRow = ({ row }: { row: PropRow }) => { className={cn("border-ink-primary/6 border-b last:border-0", open && "bg-table-tray")} > - { {row.type} - + diff --git a/components/playground-settings.tsx b/components/playground-settings.tsx index 719688d..55ffcb8 100644 --- a/components/playground-settings.tsx +++ b/components/playground-settings.tsx @@ -9,7 +9,8 @@ import { Monitor, Moon, Settings as SettingsIcon, Sun, X } from "@keyline-icons/ // deliberately persists nowhere client-side — see KeyTab. Desktop-only. import { Tabs } from "@base-ui/react/tabs"; -import { AnimatePresence, MotionConfig, motion } from "motion/react"; +import { AnimatePresence, MotionConfig } from "motion/react"; +import * as m from "motion/react-m"; import { type ReactNode, useId, useRef, useState } from "react"; import { flushSync } from "react-dom"; import type { ComposerSubmitOn } from "@/components/ai/composer"; @@ -646,7 +647,7 @@ export const PlaygroundSettings = () => { - {
- { className="py-1" > {activePanel?.content} - +
-
+
diff --git a/components/providers.tsx b/components/providers.tsx index ee2ca4d..0251975 100644 --- a/components/providers.tsx +++ b/components/providers.tsx @@ -1,19 +1,30 @@ "use client"; +import { LazyMotion, MotionConfig } from "motion/react"; import { ThemeProvider } from "next-themes"; import Tooltip from "@/components/ui/tooltip"; import { useThemeOverrides } from "@/hooks/use-theme-overrides"; +const loadMotionFeatures = () => import("@/lib/motion-features").then((module) => module.default); + const ThemeOverridesApplier = () => { useThemeOverrides(); return null; }; +// `strict` throws if a full `motion.*` component renders inside, which would +// pull the whole engine back into the first load — use `m.*` from +// "motion/react-m". reducedMotion="user" snaps size and transform animations +// when the OS asks for reduced motion. export const Providers = ({ children }: { children: React.ReactNode }) => { return ( - {children} + + + {children} + + ); }; diff --git a/components/ui/progressive-blur.tsx b/components/ui/progressive-blur.tsx index c5ade3c..dc2eb2d 100644 --- a/components/ui/progressive-blur.tsx +++ b/components/ui/progressive-blur.tsx @@ -1,5 +1,6 @@ "use client"; -import { type HTMLMotionProps, motion } from "motion/react"; +import type { HTMLMotionProps } from "motion/react"; +import * as m from "motion/react-m"; import { cn } from "@/lib/utils"; export const GRADIENT_ANGLES = { @@ -43,7 +44,7 @@ export const ProgressiveBlur = ({ const gradient = `linear-gradient(${angle}deg, ${gradientStops.join(", ")})`; return ( - ({ value, direction: shown.direction, register }), + [value, shown.direction, register], + ); + return ( - +
{shown === value ? ( - {children} - + ) : null} ); diff --git a/components/ui/text-loop.tsx b/components/ui/text-loop.tsx index 53f24e8..b2929db 100644 --- a/components/ui/text-loop.tsx +++ b/components/ui/text-loop.tsx @@ -1,6 +1,7 @@ "use client"; -import { AnimatePresence, motion } from "motion/react"; +import { AnimatePresence } from "motion/react"; +import * as m from "motion/react-m"; import type { ComponentProps } from "react"; import { useLoop } from "@/hooks/use-loop"; import { cn } from "@/lib/utils"; @@ -18,7 +19,7 @@ const TextLoop = ({ {...props} > - {currentItem} - +
); diff --git a/lib/motion-features.ts b/lib/motion-features.ts new file mode 100644 index 0000000..616647b --- /dev/null +++ b/lib/motion-features.ts @@ -0,0 +1,6 @@ +// Loaded on demand by the LazyMotion provider so the animation engine ships in +// its own chunk instead of every route's first load. domMax, not domAnimation: +// attachment chips use the `layout` prop. +import { domMax } from "motion/react"; + +export default domMax; diff --git a/packages/chat/src/ask-user/ask-user.tsx b/packages/chat/src/ask-user/ask-user.tsx index 7e43e02..473b6fd 100644 --- a/packages/chat/src/ask-user/ask-user.tsx +++ b/packages/chat/src/ask-user/ask-user.tsx @@ -14,7 +14,9 @@ import { useCallback, useId, useImperativeHandle, + useInsertionEffect, useLayoutEffect, + useMemo, useRef, useState, } from "react"; @@ -40,7 +42,8 @@ export const AskUserRoot = ({ className, render, style, ...elementProps }: AskUs { className, render, style }, { props: [{ "data-ask-user": "" }, elementProps] }, ); - return {element}; + const ids = useMemo(() => ({ labelId, stepLabelId }), [labelId, stepLabelId]); + return {element}; }; /** Question heading text. Carries the id `Options` uses as its accessible name. */ @@ -198,9 +201,14 @@ export const AskUserOptions = ({ const { labelId, stepLabelId } = use(AskUserIdsContext); const [highlightedValue, setHighlightedValue] = useState(null); - // Track highlight in a ref so imperative methods see latest value without re-binding + // Track highlight in a ref so imperative methods see latest value without re-binding. + // Mirrored after commit, not during a render React may discard, and in an + // insertion effect: it runs before every layout effect in the tree, and the + // options register in theirs, where they may seed the highlight imperatively. const highlightedValueRef = useRef(highlightedValue); - highlightedValueRef.current = highlightedValue; + useInsertionEffect(() => { + highlightedValueRef.current = highlightedValue; + }); const register = useCallback((itemValue: string, getElement: () => HTMLElement | null) => { registeredItems.current = [...registeredItems.current, itemValue]; @@ -307,20 +315,19 @@ export const AskUserOptions = ({ }, ); - return ( - - {element} - + const options = useMemo( + () => ({ + multiSelect, + groupName, + highlightedValue, + items: registeredItems, + register, + onItemHover, + }), + [multiSelect, groupName, highlightedValue, register, onItemHover], ); + + return {element}; }; /** Selectable card. Self-registers with parent `Options` on mount (cmdk pattern). Derives highlight state from context. */ @@ -370,7 +377,9 @@ export const AskUserOption = ({ // Self-register on mount, deregister on unmount (true subscription side effect) const registerRef = useRef(register); - registerRef.current = register; + useInsertionEffect(() => { + registerRef.current = register; + }); useItemRegistration(value, registerRef, optionRef); const element = useRenderElement( @@ -412,7 +421,11 @@ export const AskUserOption = ({ }, ); - return {element}; + const option = useMemo( + () => ({ id, value, selected, onSelect }), + [id, value, selected, onSelect], + ); + return {element}; }; /** Registers an item value + element with the parent Options container synchronously before paint and deregisters on unmount. */ diff --git a/packages/chat/src/composer/command-list.tsx b/packages/chat/src/composer/command-list.tsx index e8c44e0..8cb509f 100644 --- a/packages/chat/src/composer/command-list.tsx +++ b/packages/chat/src/composer/command-list.tsx @@ -184,7 +184,7 @@ export const ComposerCommand = ({ // Renders while this prefix is the active one AND through its close animation. `active` // flips false the instant the trigger closes, but the store keeps `present` sticky until - // the Panel's exit animation finishes (finalizePanelClose). `wasActiveRef` marks that this + // the Panel's exit animation finishes (finalizePanelClose). `wasActive` marks that this // prefix — not a sibling — is the one closing, so only it keeps rendering during the window. const isActive = useComposer( (composer) => composer.commands.active && composer.commands.trigger === prefix, @@ -192,10 +192,13 @@ export const ComposerCommand = ({ const present = useComposer((composer) => composer.commands.present); const query = useComposer((composer) => composer.commands.query); - const wasActiveRef = useRef(false); - if (isActive) wasActiveRef.current = true; - else if (!present) wasActiveRef.current = false; - const isPresent = isActive || (present && wasActiveRef.current); + // State adjusted during render (not a ref React might keep from a discarded + // render); it only flips at open and at the end of the close, so it costs a + // re-render at those two moments and nowhere else. + const [wasActive, setWasActive] = useState(false); + if (isActive && !wasActive) setWasActive(true); + else if (!isActive && !present && wasActive) setWasActive(false); + const isPresent = isActive || (present && wasActive); const config = internals.commands[prefix]; const itemsProp = config?.items ?? EMPTY_ITEMS; diff --git a/packages/chat/src/internal/collapsible.tsx b/packages/chat/src/internal/collapsible.tsx index 95b9143..4509a7c 100644 --- a/packages/chat/src/internal/collapsible.tsx +++ b/packages/chat/src/internal/collapsible.tsx @@ -17,6 +17,7 @@ import { use, useCallback, useId, + useMemo, useRef, useState, } from "react"; @@ -81,7 +82,8 @@ const CollapsibleRoot = ({ }, ); - return {element}; + const context = useMemo(() => ({ open, setOpen, panelId }), [open, setOpen, panelId]); + return {element}; }; export type CollapsibleTriggerProps = PrimitiveProps<"button", CollapsibleState>; diff --git a/packages/chat/src/tabs/anchored.tsx b/packages/chat/src/tabs/anchored.tsx index 30f6d0a..8338894 100644 --- a/packages/chat/src/tabs/anchored.tsx +++ b/packages/chat/src/tabs/anchored.tsx @@ -1,7 +1,7 @@ "use client"; import type { ReactNode } from "react"; -import { createContext, use, useCallback, useRef, useState } from "react"; +import { createContext, use, useCallback, useMemo, useRef, useState } from "react"; import { useIsomorphicLayoutEffect } from "../internal/iso-layout-effect"; import { Portal } from "../internal/portal"; import type { PrimitiveProps } from "../internal/primitive-props"; @@ -85,11 +85,10 @@ export const TabsPortal = ({ children, container, keepMounted = false }: TabsPor // blinking out. Popup reports when that has finished. const { mounted, setMounted, transitionStatus } = useTransitionStatus(open); - const transition: SurfaceTransition = { - transitionStatus, - open, - onExitFinished: () => setMounted(false), - }; + const transition = useMemo( + () => ({ transitionStatus, open, onExitFinished: () => setMounted(false) }), + [transitionStatus, open, setMounted], + ); if (!mounted && !keepMounted) return null;