diff --git a/frontend/apps/artcraft-webapp/src/components/motion/reveal.tsx b/frontend/apps/artcraft-webapp/src/components/motion/reveal.tsx index c208ee26d3..9a4a118b1d 100644 --- a/frontend/apps/artcraft-webapp/src/components/motion/reveal.tsx +++ b/frontend/apps/artcraft-webapp/src/components/motion/reveal.tsx @@ -16,6 +16,23 @@ import type { ElementType, ReactNode } from "react"; import { motion, useReducedMotion, type Variants } from "framer-motion"; import { DUR_SLOW, EASE_OUT } from "../../lib/motion"; +// `motion(as)` returns a BRAND-NEW component on every call. Calling it during +// render would hand each render a different component type, so React would +// unmount and remount the whole subtree on every re-render — stealing focus +// from any inside (this is what broke the login form: typing a +// character re-rendered the page and blurred the field). Cache one motion +// component per element type so the reference stays stable across renders. +const motionTagCache = new Map(); + +function motionTag(as: ElementType): ElementType { + let cached = motionTagCache.get(as); + if (!cached) { + cached = motion(as) as ElementType; + motionTagCache.set(as, cached); + } + return cached; +} + interface RevealProps { children: ReactNode; /** Render as a different element/component (e.g. "section", "li", Link). */ @@ -49,7 +66,7 @@ export function RevealGroup({ className?: string; }) { const reduceMotion = useReducedMotion(); - const MotionTag = motion(as); + const MotionTag = motionTag(as); const variants: Variants = { initial: {}, @@ -83,7 +100,7 @@ export function Reveal({ className, }: RevealProps) { const reduceMotion = useReducedMotion(); - const MotionTag = motion(as); + const MotionTag = motionTag(as); const variants: Variants = { initial: reduceMotion ? { opacity: 0 } : { opacity: 0, y },