마우스를 뗐는데 hover가 안 풀려요
클릭 직후가 아니라 조금 뒤에 레이아웃이 바뀌면 Chrome만 hover를 안 놔줘요.
버튼을 눌러 오른쪽 드로워를 열었다가 닫았어요. 닫힘 애니메이션이 없어서 순식간이었고, 손은 이미 버튼에서 떨어져 있었죠. 그런데도 버튼은 계속 hover 색이에요. Chromium 은 레이아웃이 바뀐 다음 프레임에서 포인터 아래를 다시 따지는데, 그 직후에 페이지가 조용해지면 그 프레임이 영영 안 와서 판정이 그대로 굳어요.
재현이 안 되는 데는 이유가 있어요
이 버그는 재현시키기가 까다로워요. 저도 처음엔 "클릭하면 요소가 옆으로 이동하는" 예제를 만들어놓고 왜 안 되나 한참 헤맸거든요. 원인은 예제 쪽에 있었어요. 클릭이 직접 레이아웃을 바꾸면 절대 재현되지 않아요.
Chrome 151 에서 두 경우를 갈라서 재봤어요. 포인터를 버튼 위에 올려둔 뒤 한 번도 움직이지 않고, 레이아웃을 바꾸는 주체만 달리했습니다.
| 레이아웃을 바꾼 주체 | pointerout | 2초 뒤 hover |
|---|---|---|
| 클릭 핸들러가 그 자리에서 | +21ms 발화 | 풀림 |
| 클릭과 무관한 타이머가 나중에 | 발화 없음 | 남아 있음 |
클릭은 그 자체로 입력 파이프라인을 돌려요. 그 과정에서 포인터 아래가 어디인지 다시 따지니까 hover 도 같이 정리되고요. 그래서 클릭이 곧 레이아웃 변경인 예제는 원리상 이 현상을 보여줄 수 없어요.
실제 드로워는 그렇게 동작하지 않죠. 클릭은 상태만 바꾸고, 실제로 닫히는 건 프라미스가 풀린 뒤거나 이펙트가 돈 뒤거나 타이머가 끝난 뒤예요. 그 시점엔 입력 처리가 이미 끝나 있어요.
아래가 그 형태의 최소 재현이에요. 파일로 저장해서 브라우저로 직접 열고, 버튼에 마우스를 올린 채 누른 다음 손을 그대로 두세요. 0.7초 뒤 버튼이 옮겨가는데 색은 따라옵니다.
<style>
#btn { padding: 12px 24px; background: #fff; border: 1px solid #6366f1; }
#btn:hover { background: #4338ca; color: #fff; }
#btn.moved { margin-left: 320px; }
</style>
<button id="btn">여기에 마우스를 올리고 눌러보세요</button>
<script>
const btn = document.getElementById("btn");
btn.addEventListener("pointerout", () => console.log("pointerout"));
btn.addEventListener("click", () => {
setTimeout(() => btn.classList.add("moved"), 700);
});
</script>Chrome 151 에서 콘솔에 pointerout 이 한 줄도 안 찍혀요. Firefox 와 Safari 는 찍히고 색도 정상적으로 풀리고요.
평소 같으면 이 자리에 눌러볼 수 있는 데모를 넣었을 텐데, 이번엔 못 넣었어요. 넣어보고 재봤더니 pointerout 이 정상적으로 발화하면서 색이 풀리더라고요. 글 안에 박히는 실행 환경은 그 자체로 조용하지 않아서, 재현 조건을 깨뜨려요. 데모를 못 싣는 이유가 곧 이 현상의 조건인 셈이에요.
다음 프레임이 안 오면 판정이 굳어요
조건이 하나 더 있어요. 레이아웃이 바뀐 뒤 페이지가 프레임을 계속 만들고 있으면 Chromium 은 23~57ms 안에 알아서 정리해요. 아무것도 안 하는 페이지에서만 굳습니다.
무엇이 정리를 유발하는지 하나씩 갈라봤어요. 전부 같은 조건에서 레이아웃 변경 직후에 한 줄만 다르게 넣은 결과예요.
| 레이아웃 변경 직후에 한 일 | 결과 |
|---|---|
| 아무것도 안 함 | 굳음 |
setTimeout(fn, 0) | 굳음 |
requestAnimationFrame 1회 | 굳음 |
requestAnimationFrame 중첩 2회 | +28ms 정리 |
getComputedStyle(el).color | 굳음 |
el.getBoundingClientRect() | +32ms 정리 |
el.offsetTop | +46ms 정리 |
CSS transition 250ms | +47ms 정리 |
두 가지가 보여요. 첫째, 프레임 한 개로는 부족하고 그다음 프레임이 와야 해요. 레이아웃 변경을 반영한 프레임이 아니라 그 뒤에 오는 프레임에서 정리가 일어나거든요. 둘째, 스타일 재계산만으로는 안 되고 레이아웃까지 확정돼야 해요. getComputedStyle 로 색만 읽으면 그대로지만 getBoundingClientRect 나 offsetTop 처럼 레이아웃을 강제하는 읽기면 정리됩니다.
transform 으로 옮겨도 결과는 같았어요. 레이아웃이냐 시각 변환이냐가 아니라 히트 테스트 지오메트리가 바뀌었느냐가 기준이라서요. 히트 테스트는 "이 좌표에 있는 게 어느 요소인가" 를 브라우저가 판정하는 절차예요.
여기서 한 번 짚고 갈 게 있어요. 저는 처음에 "리플로우로 생긴 위치 변화는 히트 테스트가 안 쳐주는구나" 로 이해했는데, 그건 틀렸어요. 제외가 아니라 지연이에요. Chromium 도 레이아웃이 바뀌면 포인터 아래를 다시 따집니다. 다만 그 자리에서 하지 않고 다음 프레임으로 미룰 뿐이고요.
두 줄이 이걸 갈라줍니다. 표의 마지막 줄에서 전환을 걸면 같은 리플로우인데도 +47ms 에 풀렸고, 앞 절의 표에서 클릭이 그 자리에서 레이아웃을 바꿨을 때도 +21ms 에 풀렸어요. 리플로우가 정말 판정에서 빠져 있다면 이 둘도 굳어야 하는데 안 그랬거든요. 빠진 게 아니라, 미뤄둔 재판정을 실행시킬 계기가 안 왔던 거예요.
표의 마지막 줄이 실무에서 제일 쓸모 있어요. 닫힘에 전환을 걸면 전환이 도는 동안 프레임이 계속 나오고, 그래서 정리가 일어나요. 저는 처음에 전환이 사람에게 마우스 움직일 시간을 벌어주는 것뿐이라고 생각했는데 재보니 반대였어요. 브라우저 쪽에서 실제로 고쳐줍니다.
엔진마다 시계가 달라요
같은 재현을 세 엔진에서 돌렸어요. 클릭 뒤 타이머가 레이아웃을 바꾸고, 포인터는 고정한 채로요.
| 엔진 | pointerout | hover |
|---|---|---|
| Chrome 151 | 발화 없음 | 굳음 |
| Firefox 148 | +0ms | 즉시 풀림 |
| WebKit 26.4 | +202ms | 늦게 풀림 |
Chromium 은 143, 147, 151 을 같은 하네스로 돌렸는데 셋 다 동일했어요. 버전 문제가 아니라는 뜻이에요. WebKit 의 200ms 언저리 지연은 여러 번 돌려도 일정했고요.
왜 이렇게 갈리냐면 명세가 한 방향을 가리키지 않아서예요. Pointer Events 명세에는 "멈춰 있는 포인팅 디바이스 아래에서 레이아웃 변경으로 히트 테스트 경계가 빠져나간 경우" pointerout 을 쏘라는 조항이 있어요.
그런데 같은 명세의 다른 문구는 포인팅 디바이스가 움직였을 때로 읽혀요. 이 충돌이 pointerevents 이슈 457 에서 그대로 지적됐고, 반대편에는 레이아웃이나 스크롤이 바뀌면 반드시 쏘라고 요구하는 uievents 이슈 154 가 있어요.
"UIEvents wants mouseover/out events w/o a movement so that hover effect is updated correctly after a layout change or scroll. This contradicts the PointerEvent spec behavior" - Mustaq Ahmed, W3C Pointer Events Working Group
Chromium 쪽 담당자가 두 명세가 서로 반대를 가리킨다고 공개적으로 적어둔 거예요. 그러니까 이건 어느 브라우저가 틀렸다기보다 아직 정리되지 않은 자리예요.
CSS 쪽은 더 조용해요. :hover 가 언제 풀리는지에 대해 CSS 2.1 은 어떤 요소가 그 상태에 들어갈 수 있는지도, 어떻게 들어가고 나오는지도 정의하지 않는다고 밝혀뒀어요. 브라우저가 픽셀을 만드는 단계가 어떻게 나뉘는지는 브라우저는 어떻게 화면을 그릴까에서 정리했어요.
고치는 순서
제일 싼 것부터 가면 돼요.
닫힘에 전환을 거는 게 첫 번째예요. 어차피 UX 상 있는 편이 낫고, 전환이 도는 동안 프레임이 나오니 hover 도 같이 정리돼요. 150ms 면 충분합니다.
레이아웃이 안 흔들리게 하는 게 두 번째예요. 드로워를 열 때 overflow: hidden 으로 스크롤을 잠그면 스크롤바가 사라지면서 본문이 통째로 밀려요. scrollbar-gutter: stable 로 자리를 미리 비워두면 애초에 히트 테스트 경계가 안 움직이고요.
둘 다 안 되면 그때 코드로 갑니다. 레이아웃을 바꾼 직후에 위치를 한 번 읽어주면 그 자리에서 정리돼요.
closeDrawer();
trigger.getBoundingClientRect(); // 레이아웃을 강제로 확정다만 이건 그 자체로 강제 리플로우예요. offsetWidth 한 줄이 프레임을 훔칠 때에서 다룬 바로 그 동작이고요. 드로워를 닫는 순간처럼 한 번만 지나가는 자리면 값이 싸지만, 반복되는 경로에 넣으면 hover 하나 고치려다 프레임을 잃어요.
정말 확실하게 가려면 hover 를 CSS 에서 걷어내고 상태로 들고 있어야 해요. 마지막 포인터 좌표를 기억해뒀다가 document.elementFromPoint() 로 지금 그 좌표 아래에 뭐가 있는지 직접 물어보는 방식이에요. 이 메서드는 브라우저가 클릭 대상을 고를 때 쓰는 규칙을 그대로 따르고, pointer-events 로 제외된 요소는 건너뛰고 그 아래를 돌려줘요. 대신 그 버튼은 이제 :hover 셀렉터로 스타일링할 수 없어져요. 디자인 시스템 공용 버튼 전체에 넣을 만한 방법은 아니고, 실제로 터지는 한두 개 트리거에만 쓰는 게 맞아요.
정리하면 이래요. hover 가 굳는 건 마우스를 안 움직여서가 아니라, 레이아웃이 바뀐 뒤 브라우저가 포인터 아래를 다시 따질 계기가 한 번도 안 왔기 때문이에요. 계기를 만들어주면 풀립니다. 전환이든, 레이아웃 확정이든, 직접 물어보는 코드든요.
참고 자료
- W3C - Pointer Events, pointerout
레이아웃 변경으로 히트 테스트 경계가 빠져나갔을 때의 발화 조건
- W3C - CSS 2.1 The dynamic pseudo-classes
상태 진입과 이탈을 CSS 가 정의하지 않는다는 문장
- W3C - public-pointer-events, 명세 충돌 지적
UIEvents 와 Pointer Events 가 서로 반대를 가리킨다는 Chromium 측 발언 원문
- w3c/uievents 이슈 154
레이아웃이나 스크롤 변경 시 경계 이벤트를 반드시 쏘자는 제안
- MDN - Document.elementFromPoint()
히트 테스트 기준 반환값과 pointer-events 로 제외된 요소 처리