From 89325c661871b5dd4ec8c578bc28d4e52469e924 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A4=80=EC=98=81?= Date: Fri, 27 Mar 2026 22:41:46 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=EC=B1=84=ED=8C=85=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=83=81=EB=8B=A8=20=EA=B3=A0=EC=A0=95=20=EA=B9=A8?= =?UTF-8?q?=EC=A7=90=EA=B3=BC=20=EB=B9=88=20=EA=B3=B5=EA=B0=84=20=EC=9E=94?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/layout/index.tsx | 4 +- src/pages/Chat/ChatRoom.tsx | 2 +- src/utils/hooks/useViewportHeightLock.ts | 82 ++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 2 deletions(-) diff --git a/src/components/layout/index.tsx b/src/components/layout/index.tsx index f3640b1..d61962e 100644 --- a/src/components/layout/index.tsx +++ b/src/components/layout/index.tsx @@ -18,6 +18,7 @@ interface LayoutProps { export default function Layout({ showBottomNav = false, contentClassName }: LayoutProps) { const { pathname } = useLocation(); const { contentPaddingClassName, hasHeader } = getHeaderPresentation(pathname); + const isChatRoomPage = /^\/chats\/\d+$/.test(pathname); const mainBackgroundClassName = pathname === '/chats' ? 'bg-white' : 'bg-background'; const { bottomNavRef, bottomOverlayInset, handleLayoutElement, layoutElement, mainRef } = useLayoutElements(showBottomNav); @@ -48,7 +49,8 @@ export default function Layout({ showBottomNav = false, contentClassName }: Layo ref={mainRef} style={mainStyle} className={cn( - 'box-border flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden', + 'box-border flex min-h-0 flex-1 flex-col [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden', + isChatRoomPage ? 'overflow-hidden' : 'overflow-y-auto overscroll-contain', mainBackgroundClassName, hasHeader && contentPaddingClassName, contentClassName diff --git a/src/pages/Chat/ChatRoom.tsx b/src/pages/Chat/ChatRoom.tsx index 61f539c..9471ba4 100644 --- a/src/pages/Chat/ChatRoom.tsx +++ b/src/pages/Chat/ChatRoom.tsx @@ -154,7 +154,7 @@ function ChatRoom() { }, [value]); return ( -
+
{ const root = document.documentElement; const body = document.body; + const scrollingElement = document.scrollingElement as HTMLElement | null; const prevBodyOverflow = body.style.overflow; const prevBodyHeight = body.style.height; const prevRootOverflow = root.style.overflow; const prevRootHeight = root.style.height; + let isEditableFocused = false; + let resetFrameId = 0; + let trailingResetFrameId = 0; + let resetTimeoutId = 0; + + const isTextInputElement = (element: EventTarget | null): element is HTMLElement => { + if (!(element instanceof HTMLElement)) return false; + + return element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element.isContentEditable; + }; + + const resetDocumentScroll = () => { + window.scrollTo(0, 0); + root.scrollTop = 0; + body.scrollTop = 0; + + if (scrollingElement) { + scrollingElement.scrollTop = 0; + } + }; + + const scheduleDocumentScrollReset = () => { + cancelAnimationFrame(resetFrameId); + cancelAnimationFrame(trailingResetFrameId); + clearTimeout(resetTimeoutId); + + resetDocumentScroll(); + resetFrameId = requestAnimationFrame(() => { + resetDocumentScroll(); + trailingResetFrameId = requestAnimationFrame(resetDocumentScroll); + }); + resetTimeoutId = window.setTimeout(resetDocumentScroll, 180); + }; + + const handleFocusIn = (event: FocusEvent) => { + isEditableFocused = isTextInputElement(event.target); + + if (isEditableFocused) { + scheduleDocumentScrollReset(); + } + }; + + const handleFocusOut = (event: FocusEvent) => { + if (!isTextInputElement(event.target)) return; + + isEditableFocused = false; + scheduleDocumentScrollReset(); + }; + + const handleViewportChange = () => { + if (isEditableFocused) { + scheduleDocumentScrollReset(); + } + }; + + const handleWindowScroll = () => { + const currentScrollTop = scrollingElement?.scrollTop ?? root.scrollTop ?? body.scrollTop ?? window.scrollY; + + if (currentScrollTop > 0) { + scheduleDocumentScrollReset(); + } + }; root.style.overflow = 'hidden'; body.style.overflow = 'hidden'; body.style.height = 'var(--viewport-height)'; root.style.height = 'var(--viewport-height)'; + scheduleDocumentScrollReset(); + + window.addEventListener('focusin', handleFocusIn); + window.addEventListener('focusout', handleFocusOut); + window.addEventListener('scroll', handleWindowScroll, { passive: true }); + + window.visualViewport?.addEventListener('resize', handleViewportChange); + window.visualViewport?.addEventListener('scroll', handleViewportChange); return () => { + cancelAnimationFrame(resetFrameId); + cancelAnimationFrame(trailingResetFrameId); + clearTimeout(resetTimeoutId); + + window.removeEventListener('focusin', handleFocusIn); + window.removeEventListener('focusout', handleFocusOut); + window.removeEventListener('scroll', handleWindowScroll); + + window.visualViewport?.removeEventListener('resize', handleViewportChange); + window.visualViewport?.removeEventListener('scroll', handleViewportChange); + root.style.overflow = prevRootOverflow; body.style.overflow = prevBodyOverflow; body.style.height = prevBodyHeight; From ee831a0ba3eac6e966bfa0551c21ccd33ba71262 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A4=80=EC=98=81?= Date: Fri, 27 Mar 2026 22:48:21 +0900 Subject: [PATCH 2/4] =?UTF-8?q?refactor:=20=EB=9D=BC=EC=9A=B0=ED=8A=B8=20?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/layout/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/layout/index.tsx b/src/components/layout/index.tsx index d61962e..889f22d 100644 --- a/src/components/layout/index.tsx +++ b/src/components/layout/index.tsx @@ -18,7 +18,7 @@ interface LayoutProps { export default function Layout({ showBottomNav = false, contentClassName }: LayoutProps) { const { pathname } = useLocation(); const { contentPaddingClassName, hasHeader } = getHeaderPresentation(pathname); - const isChatRoomPage = /^\/chats\/\d+$/.test(pathname); + const isChatRoomPage = pathname.startsWith('/chats/') && pathname !== '/chats'; const mainBackgroundClassName = pathname === '/chats' ? 'bg-white' : 'bg-background'; const { bottomNavRef, bottomOverlayInset, handleLayoutElement, layoutElement, mainRef } = useLayoutElements(showBottomNav); From e54c2b81e8e95772af205668b060d8c22dbd020e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A4=80=EC=98=81?= Date: Fri, 27 Mar 2026 22:48:49 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=EB=AC=B8=EC=84=9C=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A1=A4=20=EC=9C=84=EC=B9=98=20=EA=B0=90=EC=A7=80=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/hooks/useViewportHeightLock.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/utils/hooks/useViewportHeightLock.ts b/src/utils/hooks/useViewportHeightLock.ts index 26f44a7..83aa0f2 100644 --- a/src/utils/hooks/useViewportHeightLock.ts +++ b/src/utils/hooks/useViewportHeightLock.ts @@ -65,7 +65,12 @@ function useViewportHeightLock() { }; const handleWindowScroll = () => { - const currentScrollTop = scrollingElement?.scrollTop ?? root.scrollTop ?? body.scrollTop ?? window.scrollY; + const currentScrollTop = Math.max( + scrollingElement?.scrollTop ?? 0, + root.scrollTop, + body.scrollTop, + window.scrollY + ); if (currentScrollTop > 0) { scheduleDocumentScrollReset(); From 1dbe15fee73be1604743e9b9b977cd374f8c3e54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A4=80=EC=98=81?= Date: Fri, 27 Mar 2026 22:52:39 +0900 Subject: [PATCH 4/4] =?UTF-8?q?refactor:=20=EC=9E=85=EB=A0=A5=20=EC=9A=94?= =?UTF-8?q?=EC=86=8C=20=ED=8C=90=EB=B3=84=20=EC=9C=A0=ED=8B=B8=EA=B3=BC=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EC=A3=BC=EC=84=9D=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/hooks/useViewportHeightLock.ts | 16 +++++++++------- src/utils/ts/dom.ts | 5 +++++ src/utils/ts/viewport.ts | 8 ++------ 3 files changed, 16 insertions(+), 13 deletions(-) create mode 100644 src/utils/ts/dom.ts diff --git a/src/utils/hooks/useViewportHeightLock.ts b/src/utils/hooks/useViewportHeightLock.ts index 83aa0f2..c2fb462 100644 --- a/src/utils/hooks/useViewportHeightLock.ts +++ b/src/utils/hooks/useViewportHeightLock.ts @@ -1,4 +1,7 @@ import { useLayoutEffect } from 'react'; +import { isTextInputElement } from '@/utils/ts/dom'; + +const SCROLL_RESET_TIMEOUT_MS = 180; function useViewportHeightLock() { useLayoutEffect(() => { @@ -14,12 +17,6 @@ function useViewportHeightLock() { let trailingResetFrameId = 0; let resetTimeoutId = 0; - const isTextInputElement = (element: EventTarget | null): element is HTMLElement => { - if (!(element instanceof HTMLElement)) return false; - - return element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element.isContentEditable; - }; - const resetDocumentScroll = () => { window.scrollTo(0, 0); root.scrollTop = 0; @@ -30,6 +27,9 @@ function useViewportHeightLock() { } }; + // iOS Safari는 focus와 visualViewport 갱신 뒤에 문서 스크롤을 비동기로 다시 적용할 수 있습니다. + // 즉시 1번, 두 번의 animation frame, 그리고 기기 테스트 기반의 짧은 휴리스틱 timeout으로 + // 한 번 더 복구해 뒤늦게 들어오는 브라우저 스크롤도 최대한 잡습니다. const scheduleDocumentScrollReset = () => { cancelAnimationFrame(resetFrameId); cancelAnimationFrame(trailingResetFrameId); @@ -40,7 +40,7 @@ function useViewportHeightLock() { resetDocumentScroll(); trailingResetFrameId = requestAnimationFrame(resetDocumentScroll); }); - resetTimeoutId = window.setTimeout(resetDocumentScroll, 180); + resetTimeoutId = window.setTimeout(resetDocumentScroll, SCROLL_RESET_TIMEOUT_MS); }; const handleFocusIn = (event: FocusEvent) => { @@ -83,6 +83,8 @@ function useViewportHeightLock() { root.style.height = 'var(--viewport-height)'; scheduleDocumentScrollReset(); + // installViewportVars는 같은 신호로 CSS 변수를 갱신하고, + // 이 훅은 ChatRoom에서 문서 스크롤 복구를 위해 의도적으로 한 번 더 사용합니다. window.addEventListener('focusin', handleFocusIn); window.addEventListener('focusout', handleFocusOut); window.addEventListener('scroll', handleWindowScroll, { passive: true }); diff --git a/src/utils/ts/dom.ts b/src/utils/ts/dom.ts new file mode 100644 index 0000000..f5f1198 --- /dev/null +++ b/src/utils/ts/dom.ts @@ -0,0 +1,5 @@ +export const isTextInputElement = (element: EventTarget | null): element is HTMLElement => { + if (!(element instanceof HTMLElement)) return false; + + return element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element.isContentEditable; +}; diff --git a/src/utils/ts/viewport.ts b/src/utils/ts/viewport.ts index 42409e4..f2fbe70 100644 --- a/src/utils/ts/viewport.ts +++ b/src/utils/ts/viewport.ts @@ -1,3 +1,5 @@ +import { isTextInputElement } from '@/utils/ts/dom'; + const KEYBOARD_OPEN_THRESHOLD_PX = 120; export function installViewportVars() { @@ -6,12 +8,6 @@ export function installViewportVars() { let restingViewportHeight = 0; let restingViewportWidth = 0; - const isTextInputElement = (element: EventTarget | null): element is HTMLElement => { - if (!(element instanceof HTMLElement)) return false; - - return element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element.isContentEditable; - }; - const setViewportHeight = () => { const vv = window.visualViewport; const h = vv?.height ?? window.innerHeight;