포커스 트랩 훅의 deps에 콜백을 두면 타이핑 중 포커스를 빼앗긴다
모달에 닫기 확인 가드를 넣었더니 타이핑할 때마다 포커스가 첫 입력칸으로 튀었습니다. effect deps 에 인라인 콜백이 들어 있던 포커스 트랩 훅을 ref 로 고친 과정입니다.
모달에 "입력 중인 내용이 있으면 닫기 전에 확인"하는 가드를 넣다가 이상한 버그를 만났습니다. 닫기 요청을 가로채는 requestClose 함수를 만들어 모달의 onClose 로 넘겼을 뿐인데, 그때부터 본문 textarea 에 글자를 칠 때마다 포커스가 첫 입력칸으로 튀었습니다.
두 번째 글자부터는 엉뚱한 칸에 들어가니, 사용자 입장에서는 "이 모달은 글을 쓸 수가 없다"로 보입니다.
범인은 포커스 트랩 훅이었다
접근성용 포커스 트랩 훅이 문제였습니다. 흔한 구현은 이렇게 생겼습니다.
useEffect(() => {
previousActiveElement.current = document.activeElement;
getFocusable()[0]?.focus(); // setup: 초기 포커스
container.addEventListener("keydown", onKeyDown);
return () => {
container.removeEventListener("keydown", onKeyDown);
previousActiveElement.current?.focus(); // teardown: 이전 포커스 복원
};
}, [active, onEscape]); // 문제는 여기onEscape 는 함수입니다. 호출부가 인라인 화살표 함수를 넘기면 매 렌더마다 새 identity 가 되고, deps 에 들어 있으니 effect 가 리렌더 때마다 teardown 과 setup 을 반복합니다. 그런데 이 effect 의 teardown 과 setup 은 둘 다 포커스를 강제로 옮기는 부수효과입니다.
teardown 은 마운트 시점의 activeElement(보통 autoFocus 가 걸린 첫 입력칸)로 포커스를 되돌리고, setup 은 첫 포커스 가능 요소로 이동하는데 이것도 첫 입력칸입니다.
모달 안에서 입력값을 state 로 들고 있으면 글자 하나마다 리렌더가 일어납니다. 그러니 타이핑할 때마다 포커스가 첫 칸으로 강탈되는 거죠.
기존에 안 터졌던 건 우연이었습니다. 호출부가 안정적인 prop 을 그대로 넘기고 있었을 뿐이고, 콜백 하나를 감싸는 순간 드러났습니다.
useCallback 으로 막으면 되지 않을까?
반쪽짜리 처방입니다. 의존성에 dirty 같은 파생 state 가 끼면 그 값이 뒤집히는 렌더에서 identity 가 바뀌어 한 번은 포커스를 뺏깁니다. 한 번이라도 튀면 사용자는 알아챕니다.
콜백은 ref 로, deps 에서는 빼기
effect 의 수명은 "트랩이 살아 있는 기간"이어야 하고, 콜백의 identity 와는 무관해야 합니다. 그래서 최신 콜백만 ref 로 따라가게 하고 deps 에서 뺍니다.
const onEscapeRef = useRef(onEscape);
useEffect(() => {
onEscapeRef.current = onEscape;
}, [onEscape]);
useEffect(() => {
// ... setup / teardown (포커스 이동 포함)
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onEscapeRef.current?.();
};
container.addEventListener("keydown", onKeyDown);
return () => container.removeEventListener("keydown", onKeyDown);
}, [active]); // 콜백은 deps 에서 제외일반화하면 이렇습니다. 부수효과가 "설정"이 아니라 "이동"인 effect 는 deps 를 최소로 유지해야 합니다. 포커스 이동, 스크롤 위치 복원, 미디어 재생 위치 seek 처럼 재실행 자체가 사용자 눈에 보이는 동작이면, 재실행 트리거를 콜백 identity 같은 우발적 변화에 묶으면 안 됩니다.
회귀 테스트는 두 번 입력해야 잡힌다
이런 버그는 "한 번 입력하고 끝"인 테스트를 그냥 통과합니다. change 를 연속으로 두 번 이상 발생시키고 document.activeElement 를 확인해야 잡힙니다.
textarea.focus();
fireEvent.change(textarea, { target: { value: "a" } });
fireEvent.change(textarea, { target: { value: "ab" } });
expect(document.activeElement).toBe(textarea);값싼 가드라서 훅을 건드릴 때마다 같이 돌려 두려고 합니다.
참고