ReactDOM.flushSync 와 Repaint

plrs·2023년 11월 10일
1
post-thumbnail
post-custom-banner

요구사항

view에 반영되는 state가 있다.

  1. 버튼 onClick 핸들러를 누르면
  2. state를 변경하고
  3. 변경된 state가 DOM에 반영되고
  4. repaint가 일어난 후에
  5. blocking code (ex. alert) 를 실행하고 싶다.

보일러플레이트

import React from 'react';

export default function App(props) {
  console.log('rendering');

  const [state, setState] = React.useState(0);
  
  const handleClick = () => {{
    console.log('handleClick');
    setState((v) => v + 1);
    alert('blocking');
  };
  
  return (
    <button onClick={handleClick}>
      {state}
    </button>
  );
}

해당 코드를 실행해 보면 당연히 repaint 전에 alert가 표시되게 된다.

setState는 다음 스냅샷을 위한 commit을 할 뿐이지 동기적인 업데이트 과정이 아니다.
따라서 다음 리렌더가 발생하기 전에 alert가 표시된다.

ReactDOM.flushSync

ReactDOM.flushSync 는 두 가지 목적을 위해 사용된다.

  • automatic batching 을 opt-out
  • 동기적인 리렌더링 + DOM 업데이트

우리가 사용하는 목적은 두 번째이다.

import React from 'react';
import ReactDOM from 'react-dom';

export default function App(props) {
  console.log('rendering');

  const [state, setState] = React.useState(0);
  
  const handleClick = () => {
    ReactDOM.flushSync(() => {
      setState((v) => v + 1);
    });
	alert('blocking');
  };
  
  return (
    <button onClick={handleClick}>
      {state}
    </button>
  );
}

이상하게도 이렇게 코드를 변경해도 원하던 동작이 나오지 않는다.

ref를 활용해 DOM이 제대로 업데이트 되는지 확인해 보자.

import React from 'react';
import ReactDOM from 'react-dom';

export default function App(props) {
  console.log('rendering');

  const [state, setState] = React.useState(0);
  const ref = React.useRef(null);
  
  const handleClick = () => {
    ReactDOM.flushSync(() => {
      setState((v) => v + 1);
    });
	console.log(ref.current.textContent);
	alert('blocking');
  };
  
  return (
    <button ref={ref} onClick={handleClick}>
      {state}
    </button>
  );
}

해당 코드를 실행해 보니 console에는 DOM이 정상적으로 업데이트된다.

flushSync는 동기적 DOM 업데이트를 보장하지만 repaint는 보장하지 않는다.
따라서 alert는 repaint 이전에 실행되게 된다.

flushSync만으로는 목적 달성이 불가능하다는 것을 확인할 수 있다.

requestAnimationFrame

rAF를 활용해 repaint 이후로 alert의 실행을 지연시켜 보자.

const handleClick = () => {
  ReactDOM.flushSync(() => {
    setState((v) => v + 1);
  });
  requestAnimationFrame(() => {
    alert('blocking');
  })
};

이래도 원하는 동작이 나오지 않는다.

왜냐하면 rAF 콜백은 다음 repaint 직후가 아닌 직전에 실행되기 때문이다.

MessageChannel hack

React가 repaint 이후 useEffect를 실행하는 원리인 MessageChannel hack을 이용해 repaint 직후의 callback을 구현할 수 있다.

(참고자료)

import React from 'react';
import ReactDOM from 'react-dom';

export default function App(props) {
  console.log('rendering');

  const [state, setState] = React.useState(0);
  
  const handleClick = () => {
    ReactDOM.flushSync(() => {
      setState((v) => v + 1);
    });

    const channel = new MessageChannel();
    channel.port1.onmessage = function () {
      console.log('after repaint');
      alert('blocking');
    };
    requestAnimationFrame(function () {
      console.log('before repaint');
      channel.port2.postMessage(undefined);
    });
  };
  
  return (
    <button onClick={handleClick}>
      {state}
    </button>
  );
}

이제 원하던 대로 동작한다.

새로운 고민

위 구현에 다른 고민거리가 생겼다.
rAF는 repaint 시점 기준이기 때문에 예시 코드 기준으로는 flushSync를 제거하더라도 동일하게 동작한다.

flushSync를 제거하려면 아래와 같은 확신이 필요했다:

우연히 외부에서 예약된 repaint와 rAF가 겹쳐 우리가 의도한 frame 이 아닌 미리 예약되었던 frame 직후 alert가 표시되는 상황이 없음

그래서 아래와 같이 테스트를 진행했다.

import React from 'react';

export default function App(props) {
  console.log('rendering');

  const [state, setState] = React.useState(0);
  const ref = React.useRef(null);

  const _handleClick = () => {
    ref.current.textContent = 'unintended frame';
    setTimeout(handleClick, 0);
  };

  const handleClick = () => {
    setState((v) => v + 1);

    const channel = new MessageChannel();
    channel.port1.onmessage = function () {
      console.log('after repaint');
      console.log(ref.current.textContent);
      alert('blocking');
    };
    requestAnimationFrame(function () {
      console.log('before repaint');
      console.log(ref.current.textContent);
      channel.port2.postMessage(undefined);
    });
  };
  
  return (
    <button ref={ref} onClick={_handleClick}>
      {state}
    </button>
  );
}

다행히 반례 상황의 재현에 성공해 flushSync가 필요하다는 확신을 가질 수 있었다.
(매 번 발생하는 것은 아니고 여러번 시도했을경우 간혹 일어났다. flushSync를 사용했을 경우에는 해당 현상이 발생하지 않았다.)

후기

실무에서 이런 요구사항을 만날 일은 드물겠지만, 다양한 고민을 해볼 수 있어 좋았다.

profile
👋
post-custom-banner

0개의 댓글