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:
Tanish Manku committed 2026-06-25 13:06:07 -07:00
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 },