From ad5ae7968f1c91c301bb104e2536ee34b79f0d2f Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Thu, 25 Jun 2026 13:48:53 -0700 Subject: [PATCH] Fix login modal: stop Reveal motion wrappers from stealing input focus (#1698) 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/auth/signup-form.tsx | 146 +++++++++------ .../src/components/motion/reveal.tsx | 47 +++-- .../artcraft-webapp/src/pages/login/login.tsx | 175 ++++++++++-------- .../src/pages/signup/signup.tsx | 39 ++-- 4 files changed, 234 insertions(+), 173 deletions(-) diff --git a/frontend/apps/artcraft-webapp/src/components/auth/signup-form.tsx b/frontend/apps/artcraft-webapp/src/components/auth/signup-form.tsx index 5dd0e4a407..b1f9731941 100644 --- a/frontend/apps/artcraft-webapp/src/components/auth/signup-form.tsx +++ b/frontend/apps/artcraft-webapp/src/components/auth/signup-form.tsx @@ -18,6 +18,7 @@ import { import { refreshSession } from "../../lib/session"; import { hasActiveSubscription } from "../../lib/billing"; import { GoogleLoginButton } from "./GoogleLoginButton"; +import { Reveal, RevealGroup } from "../motion/reveal"; interface SignupFormProps { onSuccess: (isNewUser?: boolean) => void; @@ -115,74 +116,97 @@ export const SignupForm = ({ )} -
- - setEmail(e.target.value)} - placeholder="you@example.com" - autoFocus={autoFocus} - inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors" - /> -
- -
-
-
-
setPassword(e.target.value)} - placeholder="Min. 8 characters" - inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12" + type="email" + value={email} + onChange={(e) => setEmail(e.target.value)} + placeholder="you@example.com" + autoFocus={autoFocus} + inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors" /> - -
-
+ -
- -
+ +
+ +
+
+ setPassword(e.target.value)} + placeholder="Min. 8 characters" + inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12" + /> + +
+
+ + + + + -
-
-
-
- - or - -
+ {/* Secondary sign-up options continue the same cascade just after the + fields (delay continues from the form group's three children above: + 0.04 + 3 × 0.08). */} + + +
+
+
+ + or + + - + + + +
); }; diff --git a/frontend/apps/artcraft-webapp/src/components/motion/reveal.tsx b/frontend/apps/artcraft-webapp/src/components/motion/reveal.tsx index c208ee26d3..e7760a7151 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,22 +100,30 @@ export function Reveal({ className, }: RevealProps) { const reduceMotion = useReducedMotion(); - const MotionTag = motion(as); - - const variants: Variants = { - initial: reduceMotion ? { opacity: 0 } : { opacity: 0, y }, - animate: { - opacity: 1, - y: 0, - transition: { duration: DUR_SLOW, ease: EASE_OUT, delay }, - }, - }; + const MotionTag = motionTag(as); // When `inView` is left undefined the element behaves as a stagger child: // it inherits the parent RevealGroup's orchestration and must NOT drive its // own initial/animate props (that would override the cascade). const standalone = inView !== undefined; + // Only a standalone reveal sets its own `delay`. For a stagger child the + // delay MUST be omitted: framer-motion lets a child's explicit transition + // `delay` override the parent's `staggerChildren`/`delayChildren`, so leaving + // it in (even as 0) collapses the cascade and every child animates at once. + const variants: Variants = { + initial: reduceMotion ? { opacity: 0 } : { opacity: 0, y }, + animate: { + opacity: 1, + y: 0, + transition: { + duration: DUR_SLOW, + ease: EASE_OUT, + ...(standalone ? { delay } : {}), + }, + }, + }; + return ( { /> - -
{ @@ -87,93 +85,108 @@ const Login = () => {
)} -
- - setEmail(e.target.value)} - placeholder="you@example.com or username" - inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors" - /> -
-
-
-
-
setPassword(e.target.value)} - placeholder="Min. 8 characters" - inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12" + type="text" + value={email} + onChange={(e) => setEmail(e.target.value)} + placeholder="you@example.com or username" + inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors" /> - +
+ + + + -
- - -
- -
+ {isLoading ? ( + + ) : ( + "Log in" + )} + + + - - -
-
-
- - or - - + {/* Secondary sign-in options pick up the same cascade just after the + fields (delay continues from the form group's three children above: + 0.04 + 3 × 0.08). */} + + +
+
+
+ + or + + - - - + + + - - - Don't have an account?{" "} - - Sign up - - - + + + Don't have an account?{" "} + + Sign up + + + ); diff --git a/frontend/apps/artcraft-webapp/src/pages/signup/signup.tsx b/frontend/apps/artcraft-webapp/src/pages/signup/signup.tsx index 93e6ef5d07..1495f3236c 100644 --- a/frontend/apps/artcraft-webapp/src/pages/signup/signup.tsx +++ b/frontend/apps/artcraft-webapp/src/pages/signup/signup.tsx @@ -1,7 +1,7 @@ import { Link, useNavigate } from "react-router-dom"; import { AuthHeader, AuthFooter, SignupForm } from "../../components/auth"; import Seo from "../../components/seo"; -import { Reveal, RevealGroup } from "../../components/motion/reveal"; +import { Reveal } from "../../components/motion/reveal"; const Signup = () => { const navigate = useNavigate(); @@ -14,26 +14,25 @@ const Signup = () => { /> - - - navigate("/welcome")} - signupSource="artcraft" - /> - + {/* SignupForm runs its own field cascade internally (see signup-form.tsx). + The footer picks up the tail of that cascade — the form's six staggered + steps land by ~0.36s, so the footer follows at ~0.44s. */} + navigate("/welcome")} + signupSource="artcraft" + /> - - - Already have an account?{" "} - - Log in - - - - + + + Already have an account?{" "} + + Log in + + + ); };