fix: 모바일 가입 폼에서 한 글자마다 키보드 닫히는 버그 수정
원인: Field 컴포넌트를 SignupPage '안에서' 정의 → 글자 입력 → setForm 재렌더
→ Field가 매번 새 컴포넌트 타입으로 인식 → input 리마운트 → 포커스 상실
→ 모바일 키보드 닫힘. React의 대표 함정.
수정: Field를 컴포넌트 밖으로 이동, value/onChange를 props로 전달.
버그의 원리와 교훈('컴포넌트 안에서 컴포넌트를 정의하지 말 것')을
소스 주석에 학습 포인트로 기록 — 학생들이 같은 함정을 미리 배우게.
검증: 연속 입력(리렌더 3회) 후에도 동일 DOM 노드 유지·포커스 유지 확인.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
c0b783613d
commit
03fdde7495
@ -23,6 +23,36 @@ const EMPTY = {
|
||||
privacyConsent: false, // 개인정보 수집·이용 동의 — 체크해야만 가입 가능
|
||||
};
|
||||
|
||||
/**
|
||||
* 입력 한 줄 부품 (라벨 + input).
|
||||
*
|
||||
* ⚠️ 학습 포인트 — 실제로 났던 버그가 담긴 자리다.
|
||||
* 처음엔 이 컴포넌트를 SignupPage "안에" 정의했다. 그랬더니 모바일에서
|
||||
* 글자를 한 자 칠 때마다 키보드가 닫히는 버그가 났다. 왜?
|
||||
* 글자 입력 → setForm → SignupPage 재렌더 → 함수 안의 Field가 "새로 정의"됨
|
||||
* → React는 이전 Field와 다른 컴포넌트로 판단 → input을 버리고 새로 만듦(리마운트)
|
||||
* → 그 순간 포커스가 풀림 → 모바일 키보드가 닫힘.
|
||||
* 컴포넌트를 밖으로 빼면 Field의 정체(타입)가 항상 같아서 input이 유지된다.
|
||||
* 규칙: "컴포넌트 안에서 컴포넌트를 정의하지 말 것" — React의 대표 함정이다.
|
||||
* (밖으로 나오면서 form/set에 직접 접근할 수 없으니 value와 onChange를 props로 받는다.)
|
||||
*/
|
||||
function Field({ label, k, type = 'text', placeholder, autoComplete, value, onChange }) {
|
||||
return (
|
||||
<div className="field">
|
||||
<label htmlFor={`su-${k}`}>{label}</label>
|
||||
<input
|
||||
id={`su-${k}`}
|
||||
className="input"
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={(e) => onChange(k, e.target.value)}
|
||||
placeholder={placeholder}
|
||||
autoComplete={autoComplete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SignupPage() {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
@ -102,23 +132,7 @@ export default function SignupPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// 이 페이지 안에서만 쓰는 입력 한 줄 부품 — 필드가 많으니 반복을 줄인다.
|
||||
function Field({ label, k, type = 'text', placeholder, autoComplete }) {
|
||||
return (
|
||||
<div className="field">
|
||||
<label htmlFor={`su-${k}`}>{label}</label>
|
||||
<input
|
||||
id={`su-${k}`}
|
||||
className="input"
|
||||
type={type}
|
||||
value={form[k]}
|
||||
onChange={(e) => set(k, e.target.value)}
|
||||
placeholder={placeholder}
|
||||
autoComplete={autoComplete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 입력 부품(Field)은 파일 상단, 컴포넌트 밖에 정의되어 있다 — 이유는 그쪽 주석 참고.
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}>
|
||||
@ -144,9 +158,9 @@ export default function SignupPage() {
|
||||
) : (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="section-title" style={{ marginTop: 0 }}>계정</div>
|
||||
<Field label="아이디 (영문·숫자 4자 이상)" k="username" autoComplete="username" />
|
||||
<Field label="비밀번호 (8자 이상)" k="password" type="password" autoComplete="new-password" />
|
||||
<Field label="비밀번호 확인" k="passwordConfirm" type="password" autoComplete="new-password" />
|
||||
<Field label="아이디 (영문·숫자 4자 이상)" k="username" autoComplete="username" value={form.username} onChange={set} />
|
||||
<Field label="비밀번호 (8자 이상)" k="password" type="password" autoComplete="new-password" value={form.password} onChange={set} />
|
||||
<Field label="비밀번호 확인" k="passwordConfirm" type="password" autoComplete="new-password" value={form.passwordConfirm} onChange={set} />
|
||||
|
||||
<div className="field">
|
||||
<label>트랙</label>
|
||||
@ -162,7 +176,7 @@ export default function SignupPage() {
|
||||
</div>
|
||||
|
||||
<div className="section-title">인적사항</div>
|
||||
<Field label="이름" k="name" autoComplete="name" />
|
||||
<Field label="이름" k="name" autoComplete="name" value={form.name} onChange={set} />
|
||||
|
||||
{/* 회사 이메일 — 아이디만 입력하고 도메인은 고정으로 붙여 보여준다 */}
|
||||
<div className="field">
|
||||
@ -192,16 +206,16 @@ export default function SignupPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Field label="생년월일" k="birthDate" type="date" />
|
||||
<Field label="연락처" k="phone" placeholder="010-1234-5678" autoComplete="tel" />
|
||||
<Field label="주소" k="address" placeholder="시/군/구 이하" />
|
||||
<Field label="학교" k="school" placeholder="미림마이스터고등학교" />
|
||||
<Field label="전공(학과)" k="major" />
|
||||
<Field label="생년월일" k="birthDate" type="date" value={form.birthDate} onChange={set} />
|
||||
<Field label="연락처" k="phone" placeholder="010-1234-5678" autoComplete="tel" value={form.phone} onChange={set} />
|
||||
<Field label="주소" k="address" placeholder="시/군/구 이하" value={form.address} onChange={set} />
|
||||
<Field label="학교" k="school" placeholder="미림마이스터고등학교" value={form.school} onChange={set} />
|
||||
<Field label="전공(학과)" k="major" value={form.major} onChange={set} />
|
||||
|
||||
<div className="section-title">비상 연락처</div>
|
||||
<Field label="이름" k="emergencyName" />
|
||||
<Field label="관계 (예: 부, 모)" k="emergencyRelation" />
|
||||
<Field label="전화번호" k="emergencyPhone" placeholder="010-1234-5678" />
|
||||
<Field label="이름" k="emergencyName" value={form.emergencyName} onChange={set} />
|
||||
<Field label="관계 (예: 부, 모)" k="emergencyRelation" value={form.emergencyRelation} onChange={set} />
|
||||
<Field label="전화번호" k="emergencyPhone" placeholder="010-1234-5678" value={form.emergencyPhone} onChange={set} />
|
||||
|
||||
{/* ── 개인정보 수집·이용 동의 ──
|
||||
학습 포인트: 동의를 받으려면 "무엇을, 왜, 얼마나" 수집하는지 먼저 알려야 한다
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user