Nextjs13 App dir에서 페이지 이탈 방지 모달을 만들고 route.events 대체하기

김아현·2023년 8월 10일
31

프로젝트

목록 보기
2/4
post-thumbnail

개발 상황

현재 동아리에서 내가 맡은 파트는 먹팟의 생성과 수정 페이지이다. 프론트는 Nextjs13 app directory를 사용하고 있고 생성/수정 페이지는 react-hook-form과 zod, zustand를 사용하고 있다. 그래서 만드는 UI는 아래와 같은 페이지다. 총 2step으로 나뉘어있고, 첫 스텝에서 화면상 '<-'모양의 뒤로가기 버튼을 누르면 router.back()을 일으키도록 했다. 이 과정에서 작성한 내용이 있다면 페이지 이탈을 막는 모달을 띄우도록 구현해야 했다.

디자인과 요구사항

요청 사항

먹팟 글 작성/수정 페이지

  • 모달 떠야하는 상황
    • 내용 입력 후 1단계에서 ← 버튼 누른 경우
    • 내용 입력 후 새로 고침 누른 경우 (내용 입력하지 않고 뒤로가기 누른 경우 별도의 모달 없이 목록페이지로 이동됨)

이탈방지 모달 디자인

  • 디자인 요구사항으로 전달받은 내용은 위와 같았다. 브라우저에서 유저가 새로고침과 뒤로가기를 누른 경우 페이지 이탈 방지 모달을 띄워야 하는데, Nextjs13 App directory에선 이게 엄청나게 까다로워졌다.

Next.js 13 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 ) 모든 버튼에 방지 모달 띄우는 액션 걸기 (일부 성공) ….

그래도 해결해보자!

  • UI내 버튼을 이용한 페이지 이동이나, 직접 사용자가 링크를 입력해 떠나는 페이지 이동에는 모달을 띄우는 것은 가능했다. 주어진 Nextjs13 App dir 환경에서,RouteChangeProvider을 이용해서 UI상에 존재하는 모든 버튼 동작에 발생하는 event들을 ContextAPI를 통해 state를 관리했다.
  • 크롬이나 사파리 등등,, 브라우저 탭의 새로고침 버튼 뒤로가기 버튼 등에선 기본 브라우저 모달을 사용할 수 밖에 없었지만 우리 프로젝트 UI내 버튼과 링크에선 모달 컴포넌트를 사용할 수 있었다.

Try! 페이지 이탈 방지하기

run4w4y/useLeaveConfirmation.tsx
위 링크의 퍼블릭 Gist 작성자에게 무한한 감사를! 진짜 스택오버플로, 레딧, Nextjs 이슈를 다 뒤져도 관련 자료가 별로 없었는데, 이탈 방지와 관련한 단어를 검색어로 때려넣으면서 찾았다..

그러던 도중에 발견한 귀하디 귀한 자료.. app directory에서 router.events가 사라지자 모든 a 태그 이벤트를 체크하는 코드를 짠 사람이 나타났다!

사실 전체를 이해하지 못하긴 했지만 이 분의 코드를 짧게 설명하면 ContextAPI를 활용해서, 브라우저에서 Route Change context를 관리하는 RouteChangeProvider를 만들었다.

freeze request 스택안에 popstate가 발생할 경우 state를 잠시 보관한다.

RouteChangeProvider

// 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

그리고 pushStateapply()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

// 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;

useLeaveModal.tsx

그리고, 위의 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;

FreezeModal.tsx

다음으로 페이지 이탈이 일어날때마다 마운트될 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;

layout.tsx

그리고, 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로 위 코드들을 만들어주었다..!
참고용으로 깃헙 링크를 남겨둘테니, 페이지 이탈 방지와 큰 고민을 갖고 있던 분들은 살펴보면 큰 도움이 될 거 같다!

원작자 github url
원작자 분께서 배포한 npm 패키지 링크

profile
멘티를 넘어 멘토가 되는 그날까지 파이팅

9개의 댓글

comment-user-thumbnail
2023년 8월 10일

좋은 글 감사합니다. 자주 올게요 :)

1개의 답글
comment-user-thumbnail
2023년 8월 12일

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!

2개의 답글
comment-user-thumbnail
2023년 8월 14일

덕분에 좋은 해결 방법 알아갑니다 👍🏻

1개의 답글
comment-user-thumbnail
2023년 9월 4일

와 정말정말 좋은 글 감사드립니다!!! 너무 도움이 많이 됐습니다! 아직 공부 중인 개발 초보인데 주석까지 친절하게 달아주셔서 이해가 너무 잘 되었습니다 ㅎㅎ 다시 한 번 감사드립니다!

답글 달기
comment-user-thumbnail
2024년 1월 11일

필요하던 기능이었는데 잘봤습니다. 감사합니다~

답글 달기