Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[BUG] 답변 모달 띄운 상태에서 브라우저 UI 뒤로가기 누르면 알 수 없음 오류 발생 #447

Closed
leegwae opened this issue Sep 5, 2023 · 3 comments · Fixed by #448
Assignees
Labels
🚨 bug 버그 제보

Comments

@leegwae
Copy link
Member

leegwae commented Sep 5, 2023

🔄 How to reproduce bug

  1. 홈 페이지에서 패널 아이템 눌러 패널 페이지 들어간다.
  2. 패널 페이지에서 질문 아이템 눌러 질문/답변 모달 띄운다
  3. 뒤로 가기 버튼 누른다
  4. 오류 발생

📷 Screenshots

@leegwae leegwae added the 🚨 bug 버그 제보 label Sep 5, 2023
@leegwae leegwae self-assigned this Sep 5, 2023
@leegwae
Copy link
Member Author

leegwae commented Sep 5, 2023

발생 이유

The only data available is the routes that are currently rendered. If you ask for data from a route that is not currently rendered, the hook will return undefined. (https://reactrouter.com/en/main/hooks/use-route-loader-data)

  1. useRouterLoaderData는 현재 렌더링되지 않은 라우트의 로더의 데이터를 요청하면 undefined를 요청한다.
  2. 패널 페이지는 답변 모달을 여는 책임을 가진다. 또한 패널 페이지가 언마운트될 때, 답변 모달도 언마운트된다 (useOverlay 내부 구현)
// Panel.tsx

const overlay = useOverlay();

return <button onClick={() => overlay.open(<QAModal />)}/>
  1. 답변 모달은 패널 페이지 라우트 로더의 데이터를 가져와 디스트럭처링 문법을 사용한다.
const { author, ...panel } = useRouteLoaderData('panel') as Panel;
  1. 뒤로 가기 버튼을 눌러 패널 페이지를 벗어나 다른 페이지로 이동하면, 패널 페이지가 언마운트되므로 모달도 언마운트되는 것을 기대한다.
  2. 그러나 모달은 새로이 렌더링된 후 언마운트된다.

왜 새로 렌더링되고 있는 걸까?

@leegwae
Copy link
Member Author

leegwae commented Sep 5, 2023

페이지 이동 시 이전 페이지는 새로 이동한 페이지가 렌더링된 후 언마운트된다

import React, { useEffect } from 'react';

import { Link, RouterProvider, createBrowserRouter } from 'react-router-dom';

function Index(): JSX.Element {
  console.log('/index 렌더링 중');
  return <Link to="hello">/hello</Link>;
}

function Hello(): JSX.Element {
  useEffect(
    () => () => {
      console.log('/hello 클린업');
    },
    [],
  );

  return <div>Hello</div>;
}

const router = createBrowserRouter([
  {
    path: '/',
    index: true,
    element: <Index />,
  },
  {
    path: '/hello',
    element: <Hello />,
  },
]);

export function App(): JSX.Element {
  return <RouterProvider router={router} />;
}

단순화해보자.

  1. / 페이지에서 /hello 페이지로 이동한다.
  2. /hello 페이지에서 뒤로 가기를 눌러 / 페이지로 이동한다.

나는 /hello의 클린업이 먼저 실행된 후 /index의 렌더링이 시작될 것으로 예상했지만 실제로는 /index의 렌더링이 이루어진 후 /hello의 클린업이 실행된다.

페이지 A에서 페이지 B로 넘어가면, B 렌더링 -> A가 언마운트되며 클린업 실행 -> B 마운트

이에 따르면 패널 페이지에서 뒤로 가기를 눌러 페이지를 이동하면 페이지 렌더링 전 오버레이 클린업이 실행되지 않아 모달이 아직 언마운트되지 않았고, 모달이 렌더링되어 useRouterLoaderData로 undefined를 디스트럭처링 시도하면서 오류가 발생한 것이다.

@leegwae
Copy link
Member Author

leegwae commented Sep 5, 2023

페이지 이동 시 이전 페이지는 새로 이동한 페이지가 렌더링된 후 언마운트된다

import React, { useEffect } from 'react';

import { Link, RouterProvider, createBrowserRouter } from 'react-router-dom';

function Index(): JSX.Element {
  console.log('/index 렌더링 중');
  return <Link to="hello">/hello</Link>;
}

function Hello(): JSX.Element {
  useEffect(
    () => () => {
      console.log('/hello 클린업');
    },
    [],
  );

  return <div>Hello</div>;
}

const router = createBrowserRouter([
  {
    path: '/',
    index: true,
    element: <Index />,
  },
  {
    path: '/hello',
    element: <Hello />,
  },
]);

export function App(): JSX.Element {
  return <RouterProvider router={router} />;
}

단순화해보자.

  1. / 페이지에서 /hello 페이지로 이동한다.
  2. /hello 페이지에서 뒤로 가기를 눌러 / 페이지로 이동한다.

나는 /hello의 클린업이 먼저 실행된 후 /index의 렌더링이 시작될 것으로 예상했지만 실제로는 /index의 렌더링이 이루어진 후 /hello의 클린업이 실행된다.

페이지 A에서 페이지 B로 넘어가면, B 렌더링 -> A가 언마운트되며 클린업 실행 -> B 마운트

이에 따르면 패널 페이지에서 뒤로 가기를 눌러 페이지를 이동하면 페이지 렌더링 전 오버레이 클린업이 실행되지 않아 모달이 아직 언마운트되지 않았고, 모달이 렌더링되어 useRouterLoaderData로 undefined를 디스트럭처링 시도하면서 오류가 발생한 것이다.

원인

오류가 발생했던 경우와 동일한 환경을 단순화한 코드로 나타내보았다.

import React, { useEffect } from 'react';

import {
  Link,
  Outlet,
  RouterProvider,
  createBrowserRouter,
  useLoaderData,
} from 'react-router-dom';

import { OverlayProvider } from './contexts/OverlayContext';
import { useOverlay } from './hooks/useOverlay';

function Index(): JSX.Element {
  console.log('index 렌더링 중');
  useEffect(() => {
    console.log('index 렌더링 완료');
    return () => {
      console.log('index 클린업');
    };
  }, []);
  return <Link to="hello">이동</Link>;
}

function Modal(): JSX.Element {
  console.log('모달 렌더링 중');
  useEffect(() => {
    console.log('모달 렌더링 완료');
    return () => {
      console.log('모달 클린업');
    };
  }, []);

  return <div>모달</div>;
}

function Hello(): JSX.Element {
  const overlay = useOverlay();

  console.log('hello 렌더링 중');
  useEffect(() => {
    console.log('hello 렌더링 완료');
    return () => {
      console.log('hello 클린업');
    };
  }, []);

  return (
    <div>
      Hello
      <button
        type="button"
        onClick={() => {
          overlay.open(() => <Modal />);
        }}
      >
        열기
      </button>
    </div>
  );
}

function Root(): JSX.Element {
  console.log('root 렌더링 중');
  return (
    <OverlayProvider>
      <Outlet />
    </OverlayProvider>
  );
}

const router = createBrowserRouter([
  {
    path: '/',
    element: <Root />,
    children: [
      {
        path: '/',
        index: true,
        element: <Index />,
      },
      {
        path: '/hello',
        element: <Hello />,
      },
    ],
  },
]);

export function App(): JSX.Element {
  return <RouterProvider router={router} />;
}
  • OverlayProvider: 내부적으로 오버레이로 띄울 컴포넌트를 상태에 저장하고 이 상태를 렌더링한다. 이 상태에 대한 세터 함수를 OverlayContext의 값으로 내려준다.
  • useOverlay: OverlayContext의 값을 사용하는 커스텀 훅이다.
  • Root: OverlayProvider로 앱을 감싼다.
  • Index: 인덱스 페이지. 헬로우 페이지로 이동하는 버튼 있다.
  • Hello: 헬로우 페이지. useOverlay를 사용하여 모달을 여는 버튼 있다.
  • Modal: 모달로 열리는 컴포넌트

useOverlay는 내부적으로 훅을 사용하는 컴포넌트가 언마운트되면 오버레이를 언마운트한다.

  useEffect(
    () => () => {
      console.log('unmount overlay');
      unmount();
    },
    [unmount],
  );

헬로우 페이지에서 오버레이를 띄운 후 뒤로 가기 버튼을 눌러 인덱스 페이지로 이동했을 때, 헬로우 페이지가 언마운트되기 전 오버레이가 언마운트되는 것을 기대할 수 있다. 실제로 그렇게 된다.

  1. 헬로우 페이지에서 뒤로 가기 버튼을 누른다.
  2. 인덱스 페이지가 렌더링된다.
  3. 오버레이가 언마운트된다.
  4. 헬로우 페이지가 언마운트된다.
  5. 인덱스 페이지가 마운트된다.

문제는 2번과 3번 사이, 즉 인덱스 페이지가 렌더링되는 시점과 오버레이가 언마운트되는 시점 사이에 오버레이가 리렌더링될 수 있다는 점이다. Modal 컴포넌트에 useLocation 훅을 사용해보겠다.

function Modal(): JSX.Element {
  const location = useLocation();
  console.log(location);
  console.log('모달 렌더링 중');
  useEffect(() => {
    console.log('모달 렌더링 완료');
    return () => {
      console.log('모달 언마운트');
    };
  }, []);

  return <div>모달</div>;
}

명확히 오버레이가 언마운트되기 전 리렌더링되고 있고, 출력된 locationpathname을 보면 분명 오버레이는 헬로우 페이지에서 열었지만 리렌더링 시점에서 pathname/인 것을 확인할 수 있다.

최초의 문제로 돌아가보자.

  1. 답변 모달은 패널 페이지의 로더 데이터를 참조하기 위해 useRouteLoaderData를 사용한다. 그리고 이 훅은 현재 렌더링 중인 라우트가 아니면 undefined를 반환한다.
  2. 답변 모달은 패널 페이지가 언마운트되기 전에 언마운트되므로, 나는 항상 답변 모달이 렌더링되는 시점에서 현재 렌더링 중인 라우트는 패널 페이지로 보장된다고 기대했다.
  3. 그러나 사용자가 뒤로 가기 버튼을 눌러 react-router-dom의 훅들이 사용하고 있는 내부 상태를 변화시켰고, 이 훅들을 사용하고 있는 컴포넌트들이 리렌더링되었다. 답변 모달도 리렌더링되었다. 이 시점은 홈 페이지가 렌더링된 시점과 패널 페이지가 언마운트된 시점 사이이다. 즉, 답변 모달이 렌더링되는 시점에서 현재 렌더링 중인 라우트는 홈 페이지이다.
  4. 현재 렌더링 중인 라우트는 홈 페이지이므로 useRouteLoaderData('panel')undefined를 반환하였고 디스트럭처링 문법은 오류를 발생시켰다.

navigation 변화에 따른 라우트 컴포넌트의 렌더링/언마운트 시점과 react-router-dom 훅들의 상태 변화를 몰라서 발생한 이슈이다. react-router-dom 내부 구현을 뜯어봐야겠다.

결론 - 그래서 어떻게 해결할 것인가?

오버레이가 react-router-dom의 훅을 사용하면, 오버레이가 언마운트되지 않은 상태에서 navigation이 수행될 때 오버레이 언마운트전 리렌더링이 발생하므로 이를 주의하며 구현하도록 한다.

또한 답변 모달은 props drilling을 피하기 위해 로더 데이터를 사용한 것이므로, 로더에서 API를 react query를 통해 불러와 캐시에 저장하고 답변 모달에서 캐싱된 데이터를 가져오는 방법으로 우회하도록 한다.

=> 캐싱된 데이터 가져오려면 panelId 넘겨줘야하는데 그러면 props drilling 필요하다. 우선은 로더 데이터가 undefined인 경우를 분기 처리해주도록 한다

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
🚨 bug 버그 제보
Projects
None yet
1 participant