Skip to content

Overlay component can cause layout shift when used with focusable component #2614

@yangwooseong

Description

@yangwooseong

Summary

Overlay 컴포넌트가 TextField 같이 포커스 가능한 컴포넌트와 같이 사용될 때 레이아웃이 시프트 되는 버그가 있습니다.

Reproduction process

재현 방법

  1. container 로 지정한 엘리먼트의 경계에 가까이 오버레이를 위치시킵니다.
  2. 오버레이안에 너비가 넓은 TextField 컴포넌트를 사용하고 autoFocus 속성을 true로 줍니다.
  3. 오버레이를 열때마다 간헐적으로 레이아웃이 시프트됩니다.
2025_03_28_05_10_PM_1743149420.mp4

원인

오버레이를 띄울 때 2단계에 걸쳐서 보여주면서 transform 속성이 짧은 순간에 바뀌는 것이 원인입니다.

/**
* Case 1: show === true
* show -> shouldRender -> shouldShow
* shouldRender 를 true 로 설정하고, 직후에 shouldShow 를 true 로 설정하여 transition 유발
*
* Case 2: show === false
* show -> shouldShow -> (...) -> shouldRender
* shouldShow 를 false 로 설정하고, shouldRender 는 transition 필요 여부에 따라 다르게 결정함
* Case 2-1: withTransition === true
* shouldShow -> onTransitionEnd -> shouldRender
* onTransitionEnd handler 를 이용해 transition 이 끝난 다음 shouldRender 를 false 로 설정
* Case 2-2: withTransition === false
* shouldShow && shouldRender
* transition 을 기다릴 필요가 없으므로 바로 shouldRender 를 false 로 설정
*/
useEffect(() => {
if (show) {
if (shouldRender) {
window.requestAnimationFrame(() => setShouldShow(true))
} else {
window.requestAnimationFrame(() => setShouldRender(true))
}
}
if (!show) {
window.requestAnimationFrame(() => setShouldShow(false))
if (!withTransition) {
window.requestAnimationFrame(() => setShouldRender(false))
}
}
}, [show, withTransition, shouldRender, shouldShow, window])
const themeName = useThemeName()
if (!shouldRender) {
return null
}
const Content = (
<ThemeProvider themeName={themeName}>
<div
className={classNames(
styles.Overlay,
!shouldShow && styles.hidden,
withTransition && styles.transition,
className
)}
style={{
...style,
...getOverlayStyle({
containerRect: containerRect.current,
targetRect: targetRect.current,
overlay: overlayRef.current,
position,
marginX,
marginY,
keepInContainer,
show: shouldShow,
}),
}}
ref={mergedRef}
data-testid={OVERLAY_TEST_ID}
onTransitionEnd={handleTransitionEnd}
{...rest}
>
{children}
</div>
</ThemeProvider>
)

  1. shouldRender = true, shouldShow = false
    getOverlayStyle 가 호출될 때 overlayRef 가 아직 null 인 상태이기 때문에 getOverlayStyle 의 결과로 transform: translateX(0px) translateY(0px) 이 나오게 되고, opacity: 0 인 상태로 target 바로 위에 오버레이가 뜹니다. 사용자에는 아직 보이지 않지만 DOM 에 마운트는 되어 있는 상태입니다.

  2. shouldRender = true, shouldShow = true
    overlayRef 가 엘리먼트를 참조한 상태이기 때문에 getOverlayStyle이 오버레이의 position 속성에 따라 적절한 translate 값을 반환하게 되고, 그 결과 오버레이 위치가 바뀌면서 사용자에게 보이게 됩니다.

이렇게 2단계에 걸치는 렌더링은 대부분의 경우에는 문제가 안되지만, TextField 컴포넌트처럼 autoFocus 할 수 있는 요소와 같이 쓰면 첫 번째 단계에서 포커스 되면서 스크롤이 움직이게 됩니다.

제안

장기적으로는 floating-ui 같은 외부 라이브러리를 사용해서 리팩토링 하면 좋겠으나, 지금 당장에 간단하게 고치려면 첫 번째 단계 렌더링때 inert 속성을 활용해서 포커싱을 못하게 하면 될 것 같습니다. 사파리 호환성이 15.4 이상이어야 하는 점이 걸리는데, browserlist 에서 15.4 이상을 명시하고 있기 때문에 상관 없을 것 같기도 하네요. 혹시 어떻게 생각하시나요? @sungik-choi

Version of bezier-react

3.1.0

Browser

No response

Operating system

  • macOS
  • Windows
  • Linux

Additonal Information

No response

Metadata

Metadata

Assignees

Labels

bugIssues related to anything that isn't working

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions