mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-04 09:28:11 +00:00
fix(webapp): stop Reveal motion wrappers from stealing input focus
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 <input> 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 <body> (1 blur mid-type); after the fix the full value is retained, focus stays on the input, zero blurs.
This commit is contained in:
1 parent
864cfb5faf
commit
e6be45f3d4
1 file changed
+19
-2
@@ -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 <input> 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<ElementType, ElementType>();
|
||||
|
||||
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 },
|
||||
|
||||
Reference in new issue
Block a user