현재 동아리에서 내가 맡은 파트는 먹팟의 생성과 수정 페이지이다. 프론트는 Nextjs13 app directory를 사용하고 있고 생성/수정 페이지는 react-hook-form과 zod, zustand를 사용하고 있다. 그래서 만드는 UI는 아래와 같은 페이지다. 총 2step으로 나뉘어있고, 첫 스텝에서 화면상 '<-'모양의 뒤로가기 버튼을 누르면 router.back()
을 일으키도록 했다. 이 과정에서 작성한 내용이 있다면 페이지 이탈을 막는 모달을 띄우도록 구현해야 했다.
요청 사항
먹팟 글 작성/수정 페이지
- 모달 떠야하는 상황
- 내용 입력 후 1단계에서 ← 버튼 누른 경우
- 내용 입력 후 새로 고침 누른 경우 (내용 입력하지 않고 뒤로가기 누른 경우 별도의 모달 없이 목록페이지로 이동됨)
Nextjs13 App directory
에선 이게 엄청나게 까다로워졌다. 먼저, 지금 프로젝트에서는 Nextjs 13 App directory를 사용하고 있다!
Nextjs12
에선 router.events
에서 페이지 이탈을 쉽게 컨트롤 할 수 있었지만, Nextjs13의 App directory
에선 router.events
가 사라졌기 때문에 브라우저 상에서 발생하는 router 이벤트에 개발자가 유연하게 대응할 수 없게 되었다. (이와중에 Page directory에서는 router를 next/Router에서 import해 사용할 수 있다고 공식문서에 적혀있다.)
next/navigation의 {useRouter}에서는 router.events
가 삭제되었고 이에 따라 router.events
에 존재하는 beforePopState
를 사용할 수 없게 되었다. 이런 연유로 새로고침 시 모달 이벤트를 freeze하는 것도, default message custom에도 어려움을 겪어 UI 상의 페이지 이탈 액션에 대해서만 모달 오픈을 적용해야 했다.
app-router-migration 관련 공식문서 내용
1 ) useRouter app dir suspense로 감싸기 (실패)
2 ) 모든 버튼에 방지 모달 띄우는 액션 걸기 (일부 성공) ….
router.events
혹은 router.beforePopState
를 쓰거나 직접 js로 window.onbefroeunload로 에러 케이스별로 관리하거나 addListner로 웹에서 일어날 수 있는 모든 네비게이션 이벤트를 감지해야 할 것 같다. 아래 링크는 낮은 버전에서 route change event를 다루는 방법으로 router.events
로 편안하게 해결한다.RouteChangeProvider
을 이용해서 UI상에 존재하는 모든 버튼 동작에 발생하는 event들을 ContextAPI를 통해 state를 관리했다.run4w4y/useLeaveConfirmation.tsx
위 링크의 퍼블릭 Gist 작성자에게 무한한 감사를! 진짜 스택오버플로, 레딧, Nextjs 이슈를 다 뒤져도 관련 자료가 별로 없었는데, 이탈 방지와 관련한 단어를 검색어로 때려넣으면서 찾았다..
그러던 도중에 발견한 귀하디 귀한 자료.. app directory에서 router.events
가 사라지자 모든 a
태그 이벤트를 체크하는 코드를 짠 사람이 나타났다!
사실 전체를 이해하지 못하긴 했지만 이 분의 코드를 짧게 설명하면 ContextAPI를 활용해서, 브라우저에서 Route Change context를 관리하는 RouteChangeProvider
를 만들었다.
freeze request 스택안에 popstate가 발생할 경우 state를 잠시 보관한다.
// FreezeRequestContext를 선언
const FreezeRequestsContext = React.createContext<FreezeRequestsContextValue>({
freezeRequests: [],
setFreezeRequests: () => {},
});
export const useFreezeRequestsContext = () => {
const { freezeRequests, setFreezeRequests } = useContext(FreezeRequestsContext);
return {
freezeRequests,
// url 이동 요청을 context에 추가
request: (sourceId: string) => {
setFreezeRequests([...freezeRequests, sourceId]);
},
// url 이동 요청을 삭제
revoke: (sourceId: string) => {
setFreezeRequests(freezeRequests.filter((x) => x !== sourceId));
},
};
};
그리고 CustomEvent
를 통해 dispatch detail에 targetUrl을 담고 다른 함수들에서 상황에 맞게 url을 사용할 수 있다.
참고) Modern Javascript Tutorial - 커스텀 이벤트 디스패치
...
export const triggerRouteChangeStartEvent = (targetUrl: string): void => {
const ev = new CustomEvent('routeChangeStartEvent', { detail: { targetUrl } });
if (!isServer) window.dispatchEvent(ev);
};
...
//(중략)
...
window.addEventListener(
'routeChangeStartEvent',
(ev) => {
callbacks.onRouteChangeStart && callbacks.onRouteChangeStart(ev.detail.targetUrl);
},
{ signal: abortController.signal },
);
...
또, AbortController
를 이용해서 페이지 fetch를 제어한다. 동시에 이전에 추가한 이벤트 핸들러와 콜백 함수들을 시그널을 이용해서 제거할 수 있다. 이 코드에선 아래 예시처럼 작성해 a태그 클릭 시 이벤트 핸들러 시그널을 관리할 수 있다.
참고) AbortController는 당신의 친구입니다
// AbortController를 사용한 일부 코드
...
useEffect(() => {
const abortController = new AbortController();
const handleAnchorClick = (event: MouseEvent | ForceAnchorClickEvent) => {
const target = event.currentTarget as HTMLAnchorElement;
const isFrozen = freezeRequests.length !== 0;
if (isFrozen && !(event as ForceAnchorClickEvent).isForceAnchorClickEvent) {
event.preventDefault();
event.stopPropagation();
window.addEventListener(
'routeChangeConfirmationEvent',
(ev) => {
if (ev.detail.targetUrl === target.href) {
const forceClickEvent = createForceClickEvent(event);
target.dispatchEvent(forceClickEvent);
}
},
{ signal: abortController.signal },
);
triggerBeforeRouteChangeEvent(target.href);
return;
}
...
그 다음으로, MutationObserver
로 DOM tree의 변경까지 감지한다.
참고) Mdn web docs > MutataionObserver
그리고 pushState
의 apply()
와 getPrototypeOf()
를 Proxy 객체인 pushStateProxy
를 써서 커스텀했다.
아무튼 대략 이해한건 여기까지고..
Provider의 전체 코드는 아래와 같다.
'use client'; //클라이언트 렌더링이어야 함
import React, { useContext, useEffect, useRef, useState } from 'react';
import { nanoid } from 'nanoid';
// 브라우저 변경을 확인하기 위해 useRouteChangeEvent 훅에서 nanoid를 레퍼로 사용한다.
type HistoryURL = string | URL | null | undefined;
// targetUrl은 Freeze 이후 이동할 url
type RouteChangeStartEvent = CustomEvent<{ targetUrl: string }>;
type RouteChangeEndEvent = CustomEvent<{ targetUrl: HistoryURL }>;
type ForceAnchorClickEvent = MouseEvent & { isForceAnchorClickEvent: true };
declare global {
interface WindowEventMap {
beforeRouteChangeEvent: RouteChangeStartEvent;
routeChangeConfirmationEvent: RouteChangeStartEvent;
routeChangeStartEvent: RouteChangeStartEvent;
routeChangeEndEvent: RouteChangeEndEvent;
}
}
// url 이동 요청을 freeze
interface FreezeRequestsContextValue {
freezeRequests: string[];
setFreezeRequests: React.Dispatch<React.SetStateAction<string[]>>;
}
// server 컴포넌트인지 확인?
const isServer = typeof window === 'undefined';
// FreezeRequestContext를 선언
const FreezeRequestsContext = React.createContext<FreezeRequestsContextValue>({
freezeRequests: [],
setFreezeRequests: () => {},
});
export const useFreezeRequestsContext = () => {
const { freezeRequests, setFreezeRequests } = useContext(FreezeRequestsContext);
return {
freezeRequests,
// url 이동 요청을 context에 추가
request: (sourceId: string) => {
setFreezeRequests([...freezeRequests, sourceId]);
},
// url 이동 요청을 삭제
revoke: (sourceId: string) => {
setFreezeRequests(freezeRequests.filter((x) => x !== sourceId));
},
};
};
type PushStateInput = [data: unknown, unused: string, url: HistoryURL];
//customEvent 설정
export const triggerRouteChangeStartEvent = (targetUrl: string): void => {
const ev = new CustomEvent('routeChangeStartEvent', { detail: { targetUrl } });
if (!isServer) window.dispatchEvent(ev);
};
export const triggerRouteChangeEndEvent = (targetUrl: HistoryURL): void => {
const ev = new CustomEvent('routeChangeEndEvent', { detail: { targetUrl } });
if (!isServer) window.dispatchEvent(ev);
};
export const triggerBeforeRouteChangeEvent = (targetUrl: string): void => {
const ev = new CustomEvent('beforeRouteChangeEvent', { detail: { targetUrl } });
if (!isServer) window.dispatchEvent(ev);
};
export const triggerRouteChangeConfirmationEvent = (targetUrl: string): void => {
const ev = new CustomEvent('routeChangeConfirmationEvent', { detail: { targetUrl } });
if (!isServer) window.dispatchEvent(ev);
};
const createForceClickEvent = (event: MouseEvent): ForceAnchorClickEvent => {
const res = new MouseEvent('click', event) as ForceAnchorClickEvent;
res.isForceAnchorClickEvent = true;
return res;
};
export const RouteChangesProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [freezeRequests, setFreezeRequests] = useState<string[]>([]);
useEffect(() => {
const abortController = new AbortController(); //요청 취소 컨트롤러
const handleAnchorClick = (event: MouseEvent | ForceAnchorClickEvent) => {
const target = event.currentTarget as HTMLAnchorElement;
const isFrozen = freezeRequests.length !== 0;
if (isFrozen && !(event as ForceAnchorClickEvent).isForceAnchorClickEvent) {
event.preventDefault();
event.stopPropagation();
window.addEventListener(
'routeChangeConfirmationEvent',
(ev) => {
if (ev.detail.targetUrl === target.href) {
const forceClickEvent = createForceClickEvent(event);
target.dispatchEvent(forceClickEvent);
}
},
{ signal: abortController.signal },
);
triggerBeforeRouteChangeEvent(target.href);
return;
}
triggerRouteChangeStartEvent(target.href);
};
const handleAnchors = (anchors: NodeListOf<HTMLAnchorElement>) => {
anchors.forEach((a) => {
a.addEventListener('click', handleAnchorClick, { signal: abortController.signal, capture: true });
});
};
const handleMutation: MutationCallback = (mutationList) => {
mutationList.forEach((record) => {
if (record.type === 'childList' && record.target instanceof HTMLElement) {
const anchors: NodeListOf<HTMLAnchorElement> = record.target.querySelectorAll('a[href]');
handleAnchors(anchors);
}
});
};
const anchors: NodeListOf<HTMLAnchorElement> = document.querySelectorAll('a[href]');
handleAnchors(anchors);
const mutationObserver = new MutationObserver(handleMutation);
mutationObserver.observe(document, { childList: true, subtree: true });
const pushStateProxy = new Proxy(window.history.pushState, {
apply: (target, thisArg, argArray: PushStateInput) => {
triggerRouteChangeEndEvent(argArray[2]);
return target.apply(thisArg, argArray);
},
getPrototypeOf: (target) => {
return target;
},
});
window.history.pushState = pushStateProxy;
return () => {
mutationObserver.disconnect();
abortController.abort();
window.history.pushState = Object.getPrototypeOf(pushStateProxy);
};
}, [freezeRequests]);
return (
<FreezeRequestsContext.Provider value={{ freezeRequests, setFreezeRequests }}>
{children}
</FreezeRequestsContext.Provider>
);
};
interface RouteChangeCallbacks {
onBeforeRouteChange?: (target: string) => boolean; // if `false` prevents a route change until `allowRouteChange` is called
onRouteChangeStart?: (target: string) => void;
onRouteChangeComplete?: (target: HistoryURL) => void;
}
const useRouteChangeEvents = (callbacks: RouteChangeCallbacks) => {
const id = useRef(nanoid());
const { request, revoke } = useFreezeRequestsContext();
const [confrimationTarget, setConfirmationTarget] = useState<string | null>(null);
useEffect(() => {
request(id.current);
return () => revoke(id.current);
}, []);
useEffect(() => {
const abortController = new AbortController();
window.addEventListener(
'beforeRouteChangeEvent',
(ev) => {
const { targetUrl } = ev.detail;
const shouldProceed = callbacks.onBeforeRouteChange && callbacks.onBeforeRouteChange(targetUrl);
if (shouldProceed) {
triggerRouteChangeConfirmationEvent(targetUrl);
} else {
setConfirmationTarget(targetUrl);
}
},
{ signal: abortController.signal },
);
window.addEventListener(
'routeChangeEndEvent',
(ev) => {
callbacks.onRouteChangeComplete && callbacks.onRouteChangeComplete(ev.detail.targetUrl);
},
{ signal: abortController.signal },
);
window.addEventListener(
'routeChangeStartEvent',
(ev) => {
callbacks.onRouteChangeStart && callbacks.onRouteChangeStart(ev.detail.targetUrl);
},
{ signal: abortController.signal },
);
return () => {
abortController.abort();
};
}, [callbacks]);
return {
allowRouteChange: () => {
if (!confrimationTarget) {
console.warn('allowRouteChange called for no specified confirmation target');
return;
}
triggerRouteChangeConfirmationEvent(confrimationTarget);
},
};
};
export default useRouteChangeEvents;
진짜진짜 짱길다.. 이 Provider에 더해서, 커스텀한 Router 훅과 Modal을 띄우는 훅, Funnel에서의 사용법까지 차례대로 더 적어 보겠다.
// useCustomRouter.ts
import { useEffect, useRef, useState } from 'react';
import { useRouter as usePrimitiveRouter } from 'next/navigation';
import {
triggerBeforeRouteChangeEvent,
triggerRouteChangeStartEvent,
useFreezeRequestsContext,
} from '@/app/write/contexts/RouteChangeProvider';
interface NavigateOptions {
scroll?: boolean;
}
type AppRouterInstance = ReturnType<typeof usePrimitiveRouter>;
const createRouterProxy = (router: AppRouterInstance, isFrozen: boolean, signal?: AbortSignal) =>
new Proxy(router, {
get: (target, prop, receiver) => {
if (prop === 'push') {
return (href: string, options?: NavigateOptions) => {
const resolvePush = () => {
triggerRouteChangeStartEvent(href);
Reflect.apply(target.push, this, [href, options]);
};
if (isFrozen) {
window.addEventListener(
'routeChangeConfirmationEvent',
(ev) => {
if (ev.detail.targetUrl === href) resolvePush();
},
{ signal },
);
triggerBeforeRouteChangeEvent(href);
return;
}
resolvePush();
};
}
return Reflect.get(target, prop, receiver);
},
});
const useCustomRouter = (): AppRouterInstance => {
const router = usePrimitiveRouter();
const { freezeRequests } = useFreezeRequestsContext();
const abortControllerRef = useRef(new AbortController());
const [routerProxy, setRouterProxy] = useState<AppRouterInstance>(
createRouterProxy(router, freezeRequests.length !== 0, abortControllerRef.current.signal),
);
useEffect(() => {
return () => abortControllerRef.current.abort();
}, []);
useEffect(() => {
abortControllerRef.current.abort();
const abortController = new AbortController();
setRouterProxy(createRouterProxy(router, freezeRequests.length !== 0, abortController.signal));
return () => abortController.abort();
}, [router, freezeRequests]);
return routerProxy;
};
export default useCustomRouter;
그리고, 위의 Router Change Event들이 발생할 때, 기존에는 useOverlay 훅을 사용해 Modal을 띄웠는데, FreezeModal 컴포넌트와 함께 Form 페이지 내에서 간편하게 사용하기 위해 useLeaveModal을 작성했다.
수정 페이지와 생성 페이지 모두 공통으로 페이지 이탈 방지를 설정해야 했고 FormStore를 사용하고 있어 이 방식을 채택했다.
import { useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { useOverlay } from '@/hooks';
import useRouteChangeEvents from '@/app/write/contexts/RouteChangeProvider';
import { FreezeModal } from '@/app/write/components';
import useFormStore from '@/app/write/store/useFormStore';
const useLeaveModal = (shouldPreventRouteChange: boolean) => {
const [openModal, closeModal] = useOverlay();
const { reset } = useFormStore();
const router = useRouter();
useRouteChangeEvents({
onBeforeRouteChange: useCallback(
(targetUrl: string) => {
if (shouldPreventRouteChange) {
openModal(
<FreezeModal
onClose={closeModal} // 페이지 이탈 취소
onClick={() => { // 페이지 이탈
if (reset) reset();
router.push(targetUrl);
closeModal();
}}
/>,
);
return false;
}
return true;
},
[closeModal, openModal, reset, router, shouldPreventRouteChange],
),
});
};
export default useLeaveModal;
다음으로 페이지 이탈이 일어날때마다 마운트될 overlay modal 컴포넌트인 'FreezeModal'은 아래처럼 작성했다.
import { Button, Modal, Typography } from '@/components';
interface Props {
onClose: () => void;
onClick?: () => void;
content?: string;
footer?: string | string[];
}
const FreezeModal = ({
content,
footer,
onClose,
onClick,
}: Props) => {
return (
<Modal onClose={onClose} size="small">
<Modal.Header type="info" title="안내" />
<Modal.Content size="small">
{content}
</Modal.Content>
<Modal.Footer type="horizontal">
{footer}
</Modal.Footer>
</Modal>
);
};
export default FreezeModal;
그리고, root layout파일에 Provider를 추가한다. 우리는 Provider를 여러가지 쓰고 있어 Nested 형태가 되었다. react의 portal이란게 모달 overlay에 쓰인다고도 하는데, 아직 공부해보진 않았지만 이 Nest의 향연을 개선할 방법이 있을지도?
...
export default function RootLayout({ children }: { children: React.ReactNode }){
return(
...
<QueryProvider>
<ProfileProvider>
<RouteChangesProvider>
<OverlayProvider>{children}</OverlayProvider>
</RouteChangesProvider>
</ProfileProvider>
</QueryProvider>
...
)
...
페이지 마다 useLeaveModal 훅을 걸어준 뒤, 새로고침과 뒤로가기 이벤트(onbeforeunload)에 대해서도 동일하게 적용하려 하였으나 DOM tree내의 a태그가 아닌 유저 브라우저의 이벤트는 제어할 수 없었다..
(추가 + beforeunload
이벤트에 의해 띄워지는 새로고침 방지 모달은 커스텀이 불가능하다.
Chrome release note에 따르면, 다수의 브라우저에서 custom message 제어 기능도 빠졌다.)
beforeunload - mdn web docs
유저 사용성을 위해, 새로고침 이벤트를 freeze시키는 것과 모달 커스텀을 할 수 없게 정한 듯하다. 그래서 기본 새로고침 모달을 어떻게 커스텀할지는 아직까지 해결 방법을 찾지 못했다. 사실 우리 서비스에선 아무 페이지에서나 새로고침 이벤트를 막는게 아니라, 여러 스텝으로 나뉜 Form에 기입한 정보를 날려버리지 않기 위한 의도였지만 어찌되었건 브라우저 표준에 어긋나지 않는 웹 사용성이 우선인 거 같다.
지금은 페이지 디자인 상 존재하는 뒤로가기 버튼에 따로 onClick시 FreezModal만 띄워주도록 처리했다.
마지막으로, 뒤로가기 버튼에는 토스의 useFunnel을 참고해 app directory에서 '또' 없어진 router의 shallow copy에 대한 대응 방법도 있는데 다음에 정리해보려 한다.
NextJs13 App directory
는 상용 서비스에 적용하고자 한다면 조금 있다 사용하자!!!!!
페이지 이탈 방지 모달을 만들면서 가장 먼저 느낀 점은, 새로운 기술 스택을 선정하는 것은 양날의 검이라는 점이다.
App directory가 폴더명으로 웹 구조를 관리하기도 하고 layout이나 fetch등에서 강점을 보이지만, 각종 이슈들로 단점도 확실히 갖고 있다.
사이드 프로젝트라면 괜찮지만 큰 볼륨의 서비스엔 진입 장벽도 높고 데이터도 적다. 충분한 유저풀과 솔루션이 쌓이고 나서 사용해야할 거 같다.
다행히 중간에 router.events를 대체할 커스텀훅 구현해놓은 자료를 찾게되서 적용하겠다고 window의 갖가지 event와 contextAPI를 다시 돌아보느라 몇일이나 걸린건지... 원작자분은 정말 대단한 사람인 거 같다....
useEffect(() => {
window.addEventListener('beforeunload', handleBeforeunload);
Router.events.on('routeChangeStart', routeChangeStart);
Router.events.on('routeChangeError', routerChangeError);
return () => {
window.removeEventListener('beforeunload', handleBeforeunload);
Router.events.off('routeChangeStart', routeChangeStart);
Router.events.off('routeChangeError', routerChangeError);
};
}, [confirmed, hasChanged]);
결과적으로, Next.js13 Page directory였거나 하위 버전이었다면 짧디 짧은 위의 코드로 모르고 지나쳤을 페이지 이탈 방지의 원리를 ContextProvider에 CustomRouter, CustomEvent, dispatch, proxy.. 방대한 양의 코드를 살펴봐야 했다. 그래도 최대한 이해하려 애쓰며 성장하긴 했다!
또 한편으론, App directory의 layout등으로 pageProps를 빙빙 감싸주지 않아도 되는 간소함이 진짜 편했지만 이 부분도 이전 버전과 달라지면서 팀원끼리 꼭 상의해서 프로젝트를 진행해야 한다. 이번의 routers.events가 없어진게 갑작스레 큰 이슈를 불러와서, 프로젝트 막바지 완성 주간에 예상치 못한 공수 시간이 추가 소요됐다. 그 외에도 SSR에 의한 MediaQuery 이슈도 있고, 다양한 이슈들을 만났었는데 차차 정리해야지~
긴 글을 읽어주셔서 감사합니다. 혹시나 페이지 이탈 방지 모달을 만들기 위한 다른 아이디어가 있다면 조언해주시면 감사하겠습니다..!
추가 ++ 2023.08.15) Gist에 이 훅을 공유해준 @run4w4y가, npm package로 위 코드들을 만들어주었다..!
참고용으로 깃헙 링크를 남겨둘테니, 페이지 이탈 방지와 큰 고민을 갖고 있던 분들은 살펴보면 큰 도움이 될 거 같다!
Wow... I didn't ever expect that someone would write a whole article covering my code in such great detail, thank you very much!
However, I would advise anyone looking to use this code in production to not, unless they absolutely must, since it is not well-tested.
I also have to apologize, as I do not speak Korean so I cannot fully comprehend your article, although running it through Google Translate I noticed you mentioning beforeunload
events usage for prevention of native browser navigation and lack of customization for modals. As for the beforeunload
events, unfortunately, I am pretty sure you cannot customize either the text content of these modals or the browser modals themselves for security concerns, at least in most, if not all, modern browsers.
Once again, thank you for covering this caveat in great detail and providing people with a possible workaround!
좋은 글 감사합니다. 자주 올게요 :)