From e6be45f3d4ce2c94a9e1c975a67c39322f534770 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Thu, 25 Jun 2026 13:06:07 -0700 Subject: [PATCH] fix(webapp): stop Reveal motion wrappers from stealing input focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reveal/RevealGroup called `motion(as)` inside the render body. Unlike the cached `motion.div` accessor, calling `motion()` as a function returns a brand-new component type on every invocation. So each re-render produced a different element type at that position, React unmounted and remounted the whole subtree, and any focused inside (the login/signup/forgot-password forms) lost focus on every keystroke — you could only type one character at a time. Cache one motion component per element type via a module-level map so the component reference stays stable across renders. React now reconciles in place and focus is preserved. Verified with a Playwright A/B test on the login form: before the fix typing "test@example.com" left only "t" with focus on (1 blur mid-type); after the fix the full value is retained, focus stays on the input, zero blurs. --- .../src/components/motion/reveal.tsx | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) 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 },