React Fiber Architecture: ② 렌더 시작 단계(Render Begin Phase)

렌더 단계(Render Phase) 순서
1. Fiber 스택 프레임 초기화
2. hook 업데이트를 위해 저장해둔 `concurrentQueues`을 개별 `hook.queue`에 담는다.
3. 이전 props와 다음 props를 비교해 컴포넌트 실행 여부 결정한다.
    3-1. props가 동일하다면 자식 Fiber 노드만 생성/업데이트하고 넘어간다.
    3-2. props가 달라졌다면 대응하는 컴포넌트를 실행해 반환된 ReactElement로부터 자식 Fiber 노드를 생성/업데이트한다.
4. 자식 Fiber 노드가 없을 때까지 3번 과정을 반복한다.
5. Complete Phase
6. 정합성을 갖춘 트리가 되도록 렌더링 결과에 따라 다시 렌더링 후, 커밋 단계(Commit Phase) 준비

 

 

1. 렌더링 사전 작업

`performWorkOnRoot()`를 시작점으로 렌더 단계(Render Phase)에 돌입한다. `performWorkOnRoot()`는 렌더링 작업을 쪼개서 처리할지 판단한 후 동기적으로 렌더링하거나(`renderRootSync()`), concurrent 방식으로 렌더링(`renderRootConcurrent()`)한다.

// packages\react-reconciler\src\ReactFiberWorkLoop.js
export function performWorkOnRoot(root: FiberRoot, lanes: Lanes, forceSync: boolean) {
  const shouldTimeSlice = /* ... */;

  // ✴️ 렌더링(Render Phase) 시작: 일반적으로는 renderRootConcurrent이 실행된다
  let exitStatus = shouldTimeSlice
    ? renderRootConcurrent(root, lanes)
    : renderRootSync(root, lanes, true);

  // while 루프를 돌면서 렌더링 결과(상태값)에 따라 후속 조치
  do {
    // 1️⃣ 렌더링이 아직 진행중인 경우
    if (exitStatus === RootInProgress) {
      break;
    // 2️⃣ 렌더링이 종료된 경우(RootError, RootSuspended, RootCompleted 등)
    } else {
      const finishedWork: Fiber = (root.current.alternate: any);

      if (renderWasConcurrent && !isRenderConsistentWithExternalStores(finishedWork)) {
        /* 렌더링 도중 External Store가 변경된 경우 동기적으로 재렌더링 */
        continue;
      }
      if ((disableLegacyMode || root.tag !== LegacyRoot) && exitStatus === RootErrored) {
        /* 회복 가능한 에러(Concurrent Error)인 경우 동기적으로 재시도 */
      }
      if (exitStatus === RootFatalErrored) {
        /* 회복 불가능한 에러인 경우 루트 노드를 Suspend 처리하고 while 루프 종료 */
        break;
      }
      // ✳️ 전체 트리가 정합성을 갖췄다면 commit
      finishConcurrentRender(root, exitStatus, finishedWork, lanes);
    }
    break;
  } while (true);

  ensureRootIsScheduled(root);
}

렌더링 로직이 종료된 후 그 후속 처리는 `while (true)` 문에서 진행된다. 필요한 경우 렌더링을 여러 번 재시도해서 최종적으로는 정합성을 갖춘(consistent) 상태로 커밋(`finishConcurrentRender()`)을 해야 하기 때문에 루프에서 처리된다. 이제 각각의 렌더링 결과(`exitStatus`) 별로 어떠한 후속 처리를 하는지 살펴보자. 

① 아직 렌더링이 완료되지 않았다면(RootInProgress) `while` 루프에서는 처리할 작업은 없고, 다음에 렌더링을 이어서 할 수 있도록 `ensureRootIsScheduled()`로 작업을 다시 예약한다. ② 렌더링은 끝났지만 각 Fiber 노드의 상태값이 ExternalStore가 반환하는 상태값과 다르다면(InconsistentWithExternalStore) `renderRootSync()`로 루트 Fiber 노드부터 동기적으로 다시 렌더링한다. ③ 렌더링 중 임의의 값이 throw되었다면(RootErrored) retryLane을 선택해서 루트 Fiber 노드부터 동기적으로 다시 렌더링한다. 자세한 코드는 `recoverFromConcurrentError()`에서 확인할 수 있다. ④ 렌더링 중 회복 불가능한 에러가 발생했다면(RootFatalErrored) 현재 루트 Fiber 노드에 Suspend 표시를 하고 루프를 완전히 종료한다. ⑤ 렌더링이 현재 Tick에 완전히 끝난 경우(RootCompleted)에는 ExternalStore의 상태값과 일치한다면 `finishConcurrentRender()`를 실행해 커밋 단계(Commit Phase)로 넘어간다.

②, ③번 케이스에서 동기적으로 다시 렌더링을 한 이후에는 `continue`로 루프를 다시 돌면서 재렌더링된 결과를 처리한다. 재렌더링된 결과에 문제가 없다면 `finishConcurrentRender()`를 실행하고, 그렇지 않다면 ①~④ 과정을 다시 밟아 렌더링 작업을 처리한다.  

 

Fiber 노드를 순회하는 실제 렌더링 로직은 `renderRootSync()`와 `renderRootConcurrent()`에 존재하는데 concurrent 방식으로 렌더링이 진행되는 경우에 대해 살펴보자.

// packages\react-reconciler\src\ReactFiberWorkLoop.js
let workInProgressRootExitStatus: RootExitStatus = RootInProgress;
let workInProgressSuspendedReason: SuspendedReason = NotSuspended;

function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
  const prevExecutionContext = executionContext;
  executionContext |= RenderContext;
  const prevDispatcher = pushDispatcher(root.containerInfo);
  const prevAsyncDispatcher = pushAsyncDispatcher();

  // 1️⃣ 새 렌더링 작업인 경우 Fiber 스택 프레임을 새로 생성한다.
  if (workInProgressRoot !== root || workInProgressRootRenderLanes !== lanes) {
    workInProgressTransitions = getTransitionsForLanes(root, lanes);
    resetRenderTimer();
    prepareFreshStack(root, lanes);
  } else {
    /* ... */
  }

  outer: do {
    try {
      if (workInProgressSuspendedReason !== NotSuspended && workInProgress !== null) {
        /* Work Loop가 중단된 경우 케이스 별 처리 */
      }
      // 2️⃣ 렌더링 작업이 시작된다.
      else {
        workLoopConcurrentByScheduler();
      }
      break;
    } catch (thrownValue) {
      handleThrow(root, thrownValue);
    }
  } while (true);
  
  resetContextDependencies();
  popDispatcher(prevDispatcher);
  popAsyncDispatcher(prevAsyncDispatcher);
  executionContext = prevExecutionContext;

  // 3️⃣ 작업이 아직 남아있는 경우 RootInProgress 상태를 반환하고 종료
  if (workInProgress !== null) {
    return RootInProgress;
  // 3️⃣ 작업이 모두 끝난 경우: workInProgressRoot를 null로 변경하고 상태 반환
  } else {
    workInProgressRoot = null;
    workInProgressRootRenderLanes = NoLanes;
    finishQueueingConcurrentUpdates();

    return workInProgressRootExitStatus;
  }
}

`renderRootConcurrent()`에서는 dispatcher를 백업하고 스택 프레임을 생성한 후, `while` 문 내부의 `workLoopConcurrentByScheduler()`에서 본격적으로 렌더링 작업을 처리한다. 이 함수가 상태값(InProgress, Completed, Suspended, Errored)을 반환하며 종료되면, 기존에 백업해뒀던 dispatcher와 실행 컨텍스트를 복원하고, `performWorkOnRoot()`에서 후속 처리를 할 수 있도록 상태값을 반환한다. 이때 InProgress는 하나의 Fiber 트리에 대한 렌더링이 완료되기 전에 스케줄러에 의해 종료된 경우이고, Suspended는 `<Suspense>` 자식 요소에서 아직 렌더링 준비가 되지 않은 경우를 나타낸다.

// packages\react-reconciler\src\ReactFiberWorkLoop.js
type RootExitStatus = 0 | 1 | 2 | 3 | 4 | 5 | 6;

const RootInProgress = 0;
const RootFatalErrored = 1;
const RootErrored = 2;
const RootSuspended = 3;
const RootSuspendedWithDelay = 4;
const RootSuspendedAtTheShell = 6;
const RootCompleted = 5;

 

더보기

`renderRootConcurrent()`를 보면 코드의 초반부에 `pushDispatcher()`로 기존의 dispatcher를 저장한 후 렌더링 작업이 종료되면 `popDispatcher()`로 기존의 dispatcher를 되돌려 놓는 것을 확인할 수 있다. 이건 무엇을 위한 코드일까?

// packages\react-reconciler\src\ReactFiberWorkLoop.js
function pushDispatcher(container: any) {
  const prevDispatcher = ReactSharedInternals.H;
  ReactSharedInternals.H = ContextOnlyDispatcher;
  
  // 1️⃣ 최초 렌더링 시: 아직 dispatcher가 설정되지 않은 경우
  if (prevDispatcher === null) {
    return ContextOnlyDispatcher;
  } else {
    return prevDispatcher;
  }
}

`pushDispatcher()`를 보면 `ReactSharedInternals.H`가 `prevDispatcher`에 저장되고 `ContextOnlyDispatcher`로 변경된다. `ReactSharedInternals`는 React의 모든 패키지에서 공용으로 사용되는 내부 모듈로 `ReactSharedInternals.H`에는 `useState`, `useRef`, `useEffect` 등의 hook들이 담긴다. 

// packages\react\src\ReactSharedInternalsClient.js
const ReactSharedInternals: SharedStateClient = {
  H: null,
  A: null,
  T: null,
  S: null,
};
// renderWithHooks()에서...
ReactSharedInternals.H = current === null || current.memoizedState === null
  ? HooksDispatcherOnMount
  : HooksDispatcherOnUpdate;
// finishRenderingHooks()에서...
ReactSharedInternals.H = ContextOnlyDispatcher;

렌더 단계에서 `ReactSharedInternals.H`는 `renderWithHooks()`와 `finishRenderingHooks()`에서 변경된다. `ReactSharedInternals`은 모든 속성이 `null`로 초기화 되어있지만 렌더 단계에서 마운트/업데이트 각 상황에 맞는 값으로 설정된다.

`renderWithHooks()`에서는 `current`와 `current.memoizedState` 값에 따라 `ReactSharedInternals.H`를 다르게 변경한다. `current === null || current.memoizedState === null`는 어떤 경우일까? 이때 `current`는 현재 작업중인 Fiber 노드의 `alternate` 노드(`unitOfWork.alternate` = 스냅샷 노드)인데 Fiber 노드가 마운트되는 경우에는 이전 상태라는 게 존재하지 않으므로 `current === null`이다. 또한, `current.memoizedState`는 현재 Fiber 노드에 마운트된 첫번째 hook을 가리키는데, hook이 처음으로 마운트(stateless → stateful)되는 경우에 `current.memoizedState === null`이다. 정리하면, ① 현재 Fiber 노드가 마운트될 때, ② 해당 Fiber 노드에 hook이 최초로 마운트될 때 `HooksDispatcherOnMount`가 할당되고 그 외에는 `HooksDispatcherOnUpdate`가 할당된다.

 

Q. hook이 마운트/업데이트되어야 하는지는 컴포넌트마다 다른데 `ReactSharedInternals`를 전역 변수로 관리해도 문제가 없을까?

`ReactSharedInternals`는 전역적이지만 `ReactSharedInternals.H`를 변경하는 `renderWithHooks()`는 개별 Fiber 노드마다 실행되기 때문에 현재 Fiber 노드(`current`)의 상황에 맞는 값으로 `ReactSharedInternals.H`가 설정될 수 있다. 따라서 처음 마운트되는 컴포넌트에서는 `HooksDispatcherOnMount`를, 마운트 이후에 상태가 업데이트되는 컴포넌트에서는 `HooksDispatcherOnUpdate`를 사용하기 때문에 아무런 문제가 없다.

`renderWithHooks()` 후반부에서 `finishRenderingHooks()`가 실행되고 이 함수에서 `ReactSharedInternals.H`를 `ContextOnlyDispatcher`로 되돌린다. `ContextOnlyDispatcher`는 `readContext` 이외에 모든 hook이 `throwInvalidHookError()` 호출로 설정된 값인데, 렌더링 중이 아닌 상황에서는 hook을 사용할 수 없도록 차단한다.

개별 Fiber 노드에서 `ReactSharedInternals.H` 값의 변화
1. 최초 마운트 시: null → ContextOnlyDispatcher → HooksDispatcherOnMount → ContextOnlyDispatcher(렌더링 종료) → ContextOnlyDispatcher(popDispatcher)
2. 업데이트 시: ContextOnlyDispatcher → HooksDispatcherOnUpdate → ContextOnlyDispatcher(렌더링 종료) → ContextOnlyDispatcher(popDispatcher

원래의 의문으로 돌아가서, React의 dispatcher는 전역 변수인 `ReactSharedInternals.H`에 저장되는데 이것을 push했다가 렌더 단계가 끝났을 때 pop하는 의미가 있는 걸까? React에서는 `createRoot()`로 둘 이상의 루트를 생성해 렌더링할 수 있는데 `ReactSharedInternals.H`를 원래대로 돌려놓지 않는다면 다른 루트에서의 dispatcher가 꼬일 수 있기 때문이다. 예를 들어, rootA에서 `ReactSharedInternals.H = HooksDispatcherOnUpdate`로 할당하고 이것을 복원하지 않고 rootB를 렌더링하는 경우 이 루트에서는 dispatcher가 마운트되지 않았음에도 `HooksDispatcherOnUpdate`가 호출되므로 dispatcher를 제대로 사용할 수 없게 된다. 

 

* finishRenderingHooks에서 ContextOnlyDispatcher로 돌려놓는데 그럼 push, pop하지 않아도 상관없지 않나?

* prepareFreshStack은 루트에서만 한 번 실행되고 매 fiber 노드에 대해서는 실행되지 않음

 

// packages\react-reconciler\src\ReactFiberHooks.js
function updateWorkInProgressHook(): Hook {
  // ...
  if (nextCurrentHook === null) {
      const currentFiber = currentlyRenderingFiber.alternate;
      
      if (currentFiber === null) {
        throw new Error('Update hook called on initial render. This is likely a bug in React. Please file an issue.');
      } else {
        // ...
      }
    }
    // ...
}

업데이트 시에 호출되는 `updateWorkInProgressHook()` 내부를 보면 `currentlyRenderingFiber.alternate`을 체크하는 부분이 있다. Fiber 노드가 처음 마운트되는 상황이라면 이 값은 `null`이 되는데 이 때 "Update hook called on initial render..." 오류를 던지는 걸 확인할 수 있다.

이러한 상황을 방지하기 위해 `renderRootConcurrent()`에서 기존의 dispatcher를 백업한 후 `renderWithHooks()`에서 조건에 맞는 dispatcher를 설정하고 렌더 단계가 종료되면 `ReactSharedInternals.H`에 기존의 dispatcher를 돌려놓는 것이다.

 

Q. React hook을 컴포넌트 바깥에서 호출할 때 출력되는 오류는 어디에서 처리되는 걸까?

`ReactSharedInternals.H`는 react-reconciler를 포함해 React 내부 패키지에서 공용으로 사용되는 값이지만 react-dom나 react-native에서든 React hook은 react 패키지에서 import된다.

// packages\react\src\ReactHooks.js
function resolveDispatcher() {
  const dispatcher = ReactSharedInternals.H;
  
  if (__DEV__) {
    if (dispatcher === null) {
      console.error('Invalid hook call. ...');
    }
  }
  return dispatcher;
}

export function useState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const dispatcher = resolveDispatcher();
  return dispatcher.useState(initialState);
}

각각의 hook은 위의 코드에서 알 수 있듯이 `resolveDispatcher()`에서 `ReactSharedInternals.H`을 참조해 실행된다. 만약 아래와 같이 컴포넌트 모듈 파일의 최상위(top-level) 스코프에서 hook을 실행한다면 이 파일이 로드될 때는 렌더 단계가 아니라 마운트되는 단계이기 때문에 `ReactSharedInternals.H`가 아직까지는 `null`이다.

import { useState } from "react";

const [state, setState] = useState(0);

const MyComponent = () => { /* ... */ };

export default MyComponent;

 따라서 `resolveDispatcher()`에서 반환된 dispatcher가 `null`이므로 아래와 같은 런타임 오류가 발생한다.

Uncaught TypeError: Cannot read properties of null (reading 'useState')

 

 

 

A. Fiber 스택 프레임 초기화

만약 유휴 상태에서 상태 업데이트가 진행된다면 `workInProgressRoot === null`, `workInProgressRootRenderLanes === NoLane`이기 때문에 `prepareFreshStack()`이 실행되어 Fiber 스택 프레임이 새로 생성된다. 현재 루트 Fiber 노드의 렌더링 작업이 종료되어 다음 루트 Fiber 노드 렌더링 작업으로 넘어가는 경우도 마찬가지이다. 앞서 React Fiber Architecture: ① 작업 스케줄링에서 브라우저의 콜 스택을 대체하기 위해 React에서 자체적으로 스택 프레임을 구현했다고 했는데 그것이 바로 여기에 등장하는 Fiber 스택 프레임이다. 

// packages\react-reconciler\src\ReactFiberWorkLoop.js
function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
  resetWorkInProgressStack();
  workInProgressRoot = root;
  
  // ✳️ 루트 Fiber 노드의 alternate Fiber 노드를 생성한다.
  const rootWorkInProgress = createWorkInProgress(root.current, null);
  
  workInProgress = rootWorkInProgress;
  workInProgressRootRenderLanes = lanes;
  
  // ✴️ 큐에 쌓아놨던 hook 업데이트 객체를 hook.queue에 추가한다
  finishQueueingConcurrentUpdates();
}

이 함수에서 변경되는 전역 변수 `workInProgress`는 루트 Fiber 노드가 아니라 루트 Fiber 노드의 alternate 노드이다. 루트 Fiber 노드와 그 alternate 노드는 각각의 트리 구조를 이루고 있기 때문에 하위 노드도 alternate 노드가 사용된다. 한편, 현재 단계에서는 alternate 노드에 다음 화면에 보여줄 상태값은 아직 반영되어 있지 않다.

 

 

B. Concurrent 큐 취합

렌더링 작업을 스케줄링하는 단계에서 `dispatchSetStateInternal()` 코드를 보면 hook의 다음 상태를 계산하고 갱신하는 로직은 존재하지 않음을 알 수 있다. 이 단계에서는 hook의 업데이트 정보를 별도의 전역 변수 `concurrentQueues`에 전부 모아두기만 한다. 이후, 렌더 시작 단계에서 큐에 저장된 Update 객체들을 개별 `hook.queue`에 옮겨 담고, `renderWithHooks()`에서 개별 컴포넌트 각각의 hook이 실행될 때 `hook.queue`를 처리해 hook의 최종 상태값을 결정한다. 이때, 큐에 저장된 Update 객체를 개별 `hook.queue`에 담는 작업은 `prepareFreshStack()` 내부의 `finishQueueingConcurrentUpdates()`에서 처리된다.

// packages\react-reconciler\src\ReactFiberConcurrentUpdates.js
const concurrentQueues: Array<any> = [];
let concurrentQueuesIndex = 0;

export function finishQueueingConcurrentUpdates() {
  const endIndex = concurrentQueuesIndex;
  let i = 0;
  concurrentQueuesIndex = 0;
  concurrentlyUpdatedLanes = NoLanes;

  while (i < endIndex) {
    // 1️⃣ concurrentQueues에서 항목을 꺼내서 hook.queue를 구성한다
    const fiber: Fiber = concurrentQueues[i];
    concurrentQueues[i++] = null;
    // ✳️ update가 저장되어야 할 hook.queue 객체
    const queue: ConcurrentQueue = concurrentQueues[i];
    concurrentQueues[i++] = null;
    // ✳️ 변경 사항을 담은 update 객체
    const update: ConcurrentUpdate = concurrentQueues[i];
    concurrentQueues[i++] = null;
    const lane: Lane = concurrentQueues[i];
    concurrentQueues[i++] = null;

    // 2️⃣ hook.queue가 원형 연결 리스트가 되도록 구성한다
    if (queue !== null && update !== null) {
      const pending = queue.pending;
      if (pending === null) {
        // 3️⃣ queue.pending의 처음 항목은 next로 자기 자신을 설정한다
        update.next = update;
      } else {
        // 3️⃣ 다음 항목은 리스트의 마지막에 이어 붙인다
        update.next = pending.next;
        pending.next = update;
      }
      queue.pending = update;
    }
  }
}

`finishQueueingConcurrentUpdates()`에서는 `concurrentQueues`에 삽입된 Update 객체를 하나씩 꺼내 원형 연결 리스트가 되도록 `hook.queue`에 저장한다. `concurrentQueuesIndex`는 `concurrentQueues`에서 마지막 항목의 인덱스를 나타내는데, 렌더링 작업을 스케줄링할 때 `enqueueUpdate()`에서 fiber, queue, update, lane을 큐에 추가하면서 증가시키고 `finishQueueingConcurrentUpdates()`에서 큐 항목을 처리할 때 0으로 초기화한다.

한편, `queue`는 dispatcher를 소유하고 있는 hook의 `hook.queue`, `update`는 dispatcher를 실행할 때 사용자가 전달했던 값/업데이터(action)가 저장된 Update 객체이다. `concurrentQueues`에 저장되어 있는 `queue`는 업데이트가 발생한 hook의 `hook.queue`이기 때문에 hook 객체를 명시적으로 전달하지 않았음에도 해당하는 hook에 변경 사항을 예약할 수 있다.

const MyComponent = () => {
  const [count1, setCount1] = useState(0);
  const [count2, setCount2] = useState(0);
  
  const handleCountUp = () => {
    setCount1(1);
    setCount1(2);
    setCount1(3);
  }
  // ...
}

예를 들어, 위와 같이 상태 업데이트가 세 번 연이어 발생했다고 해보자. `setCount1`이 첫번째 useState의 dispatcher이고, setState에 전달한 값인 `1`이 `action`이다. 이 예시에서 `concurrentQueues`의 항목을 하나씩 꺼낼 때 업데이트 원형 리스트는 아래와 같은 과정을 거쳐 완성된다.

하나의 hook에 여러 업데이트 항목이 추가되는 경우

`finishQueueingConcurrentUpdates()`에서 `while` 문의 마지막에 보면 `hook.queue.pending = update`로 설정하는데 왜 `hook.queue.pending`을 첫번째 Update 객체가 아니라 마지막 객체로 설정하는 걸까? `hook.queue.pending`을 첫번째 객체로 설정하면 개별 hook에서 업데이트 사항을 반영할 때는 `hook.queue`의 처음부터 끝까지 단순히 순회하면 되지만, 새로운 Update 객체를 추가할 때도 매번 리스트를 순회해 마지막 노드를 찾아야 하기 때문에 비효율적이다. 하지만 `hook.queue.pending`을 마지막 객체로 설정하면 `hook.queue.pending.next`로 첫번째 Update 객체부터 순회할 수 있는 건 동일한데다가 Update 객체를 추가할 때도 O(1)로 바로 덧붙일 수 있기 때문에 효율적이다.

// packages\react-reconciler\src\ReactFiberHooks.js
function updateReducerImpl<S, A>(hook: Hook, current: Hook, reducer: (S, A) => S) {
  const queue = hook.queue;
  // ✴️ baseQueue 뒤에 pendingQueue를 이어 붙인 큐를 사용해 업데이트를 처리한다
  let baseQueue = hook.baseQueue;
  const pendingQueue = queue.pending;
  
  /* baseQueue와 pendingQueue를 합친다 */
  
  if (baseQueue === null) {
    // ...
  } else {
    // ✴️ baseQueue.next로 큐의 첫번째 Update 객체를 얻는다
    const first = baseQueue.next;
    let update = first;
    
    do {
       /* 큐의 첫번째 항목에 도달하기 전까지 hook 업데이트 사항을 적용한다 */
    } while (update !== null && update !== first);
  }
}

이렇게 모은 `hook.queue.pending`은 `renderWithHooks()`에서 컴포넌트 본문의 `useState` (`HooksDispatcherOnUpdate.useState`)가 실행될 때 `hook.memoizedState`에 반영된다. `hook.queue.pending`은 리스트의 마지막 Update 객체이므로 상태를 업데이트할 때는 이 값의 `next`를 참조해 맨 처음 객체부터 적용한다.

한편, `baseQueue`와 `pendingQueue` 두 개의 큐가 등장하는데 `baseQueue.next`는 이전 렌더링에서 반영하지 않은 낮은 우선순위의 큐, `pendingQueue`는 이번 렌더링에서 추가된 큐이다. 둘 다 hook에 반영해야 할 Update 객체이기 때문에 `hook.baseQueue`와 `hook.queue.pending`을 이어붙인 후 리스트를 순회하면서 다시 우선순위를 체크해 hook의 상태에 반영할지 결정한다.

Update 객체는 원형 연결 리스트이기 때문에 리스트를 순회했을 때 첫번째 객체에 다시 도달하게 되면 `while` 루프를 중단한다. `useState`에서 hook, queue, update 객체 간의 연결 관계를 하나로 모아서 도식화하면 아래와 같다.

hook, queue, update 객체의 연결 관계

 

 

C. 렌더링 루프 시작

// 현재 작업 중인 Fiber 노드
let workInProgress: Fiber | null = null;

function workLoopConcurrentByScheduler() {
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

`workLoopConcurrentByScheduler()`에서는 Scheduler가 허용하는 시간 내에서 `performUnitOfWork()`를 반복적으로 실행한다. 이 시간 내에 Fiber 트리의 말단까지 작업을 끝내지 못했다면 `shouldYield()`가 true를 반환하기 때문에 루프가 종료된다. 이 경우, `renderRootConcurrent()`에서 `break`에 의해 `while` 문도 종료되므로 `RootInProgress` 상태를 반환한다.

// packages\react-reconciler\src\ReactFiberWorkLoop.js
function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
  // ...
  outer: do {
    try {
      if (/* ... */) {
        // ...
      } else {
        // ✳️ 렌더링 작업이 시작된다.
        workLoopConcurrentByScheduler();
      }
      break;
    } catch (thrownValue) {
      // ...
    }
  } while (true);

  // 1️⃣ 작업이 아직 남아있는 경우 RootInProgress 상태를 반환하고 종료
  if (workInProgress !== null) {
    return RootInProgress;
  // 2️⃣ 작업이 모두 끝난 경우: workInProgressRoot를 null로 변경하고 상태 반환
  } else {
    // ...
    return workInProgressRootExitStatus;
  }
}

반면에 시간 내에 모든 렌더링 작업이 완료되었다면, 뒤에서 더 살펴보겠지만 `completeUnitOfWork()`에서 `workInProgressRootExitStatus`를 `RootCompleted`으로 바꿔 반환한다.

// packages\react-reconciler\src\ReactFiberWorkLoop.js
function completeUnitOfWork(unitOfWork: Fiber) {
  // ...
  if (workInProgressRootExitStatus === RootInProgress) {
    workInProgressRootExitStatus = RootCompleted;
  }
}

이제까지 렌더링 작업이 완료되고 커밋 단계에 진입하기 전까지 어떤 일이 일어나는지를 우선적으로 살펴봤다. 지금부터는 `performUnitOfWork()` 코드를 자세히 분석해서 렌더링 작업이 어떻게 진행되는지 살펴보자.

 

 

Q. 작업이 도중에 중단되어 나중에 재개할 때 어떻게 작업 위치를 복원하는가?

현재 작업 중인 Fiber 노드는 전역 변수 `workInProgress`에 저장해두고 관리하지만 이것을 각각의 루트 Fiber 노드에도 저장해두지는 않는다. `workInProgress`은 따로 초기화되지 않으므로 A 루트 Fiber에 예약된 작업 a가 오래 걸려 도중에 중단되었다면 작업 a를 다시 수행할 때는 중단된 Fiber 노드에서부터 렌더링이 재개된다. 하지만 A 루트 Fiber에서 렌더링이 중단된 후 B 루트 Fiber의 렌더링이 먼저 처리되거나 A 루트 Fiber에서 다른 Lane을 사용하는 작업이 예약되었다면, A 루트 Fiber로 다시 돌아왔을 때는 중단된 위치의 Fiber 노드가 아니라 루트부터 다시 시작하게 된다.

정리하면, `renderRootConcurrent()`와 `prepareFreshStack()`에서 살펴봤듯이 렌더링 작업이 진행되는 루트 Fiber나 Lane에 변경 사항이 없다면 `workInProgress` 값은 유지되므로 렌더링이 중단된 Fiber 노드에서부터 다시 시작할 수 있다.

 

 

 

2. Begin Phase

`workLoopConcurrentByScheduler()`의 `while` 문에서 실행되는 `performUnitOfWork()`는 현재 루트 Fiber 노드를 포함한 모든 하위 노드들에 대해 각각의 변경 사항을 Fiber 노드에 반영하는 역할을 한다. 재귀를 사용하는 Stack Reconciler와 달리 Fiber Reconciler는 전역 변수 `workInProgress`를 변경하면서 `while` 루프를 돌기 때문에 렌더링 작업을 도중에 중단하고 재개하는 것이 가능하다.

// packages\react-reconciler\src\ReactFiberWorkLoop.js
function performUnitOfWork(unitOfWork: Fiber) {
  const current = unitOfWork.alternate;
  // 1️⃣ root alternate 노드에서부터 작업을 시작한다.
  // beginWork가 반환하는 Fiber 노드는 child 노드
  const next = beginWork(current, unitOfWork, entangledRenderLanes);

  // ❇️ alternate(unitOfWork) 노드의 memoizedProps를 pendingProps로 교체한다
  unitOfWork.memoizedProps = unitOfWork.pendingProps;
  if (next === null) {
    // 2️⃣ 현재 Fiber 노드의 자식 노드가 없는 경우 작업 종료
    completeUnitOfWork(unitOfWork);
  } else {
    // 3️⃣ 자식 노드가 존재한다면 while 루프가 계속 진행되도록 workInProgress를 변경한다.
    workInProgress = next;
  }
}

`beginWork()`에서는 현재 `workInProgress` Fiber 노드에서의 변경 사항을 현재 노드(`unitOfWork`)에 반영하고, `child` 속성을 이용해 자식 Fiber 노드의 alternate 노드를 복사(clone)해 반환한다. 자식 노드가 존재한다면 해당 노드가 `workInProgress`에 할당되어 `workLoopConcurrentByScheduler()`의 루프가 계속 진행되며, 그렇지 않다면 `completeUnitOfWork()`를 실행해 해당 Fiber 노드의 렌더링 작업을 정리한다. 또한, 현재 노드에서 렌더링이 완료되면 `unitOfWork.memoizedProps`를 `unitOfWork.pendingProps`로 교체해서 다음 렌더링 때 반대편 Fiber 노드에서 UI 스냅샷 상태값으로 참조할 수 있도록 한다.

`unitOfWork`: 커밋할 상태값을 담고 있는 Fiber 노드. `performUnitOfWork()`에서 이번에 변경된 상태값을 반영할 노드이다.
`unitOfWork.alterate`: 이전 상태값(UI 스냅샷)을 담고 있는 Fiber 노드

`beginWork()` 코드에서 어떤 식으로 `unitOfWork` 노드에 변경 사항을 반영하는지 확인해보자.

// packages\react-reconciler\src\ReactFiberBeginWork.js
let didReceiveUpdate = false;

function beginWork(current: Fiber | null, workInProgress: Fiber, renderLanes: Lanes): Fiber | null {
  if (current !== null) {
    const oldProps = current.memoizedProps;
    const newProps = workInProgress.pendingProps;
    
    // 1️⃣ props가 달라졌다면 didReceiveUpdate를 true로 바꾸고 넘어간다
    if (oldProps !== newProps || /* ... */) {
      didReceiveUpdate = true;
    } else {
      const hasScheduledUpdateOrContext = checkScheduledUpdateOrContext(current, renderLanes);
      // 2️⃣ 대기중인 업데이트나 컨텍스트 변화가 있는지 확인
      if (!hasScheduledUpdateOrContext) {
        didReceiveUpdate = false;
        return attemptEarlyBailoutIfNoScheduledUpdate(current, workInProgress, renderLanes);
      } else {
        didReceiveUpdate = false;
      }
    }
  } else {
    // ...
  }
  
  switch (workInProgress.tag) {
    // ✴️ 컴포넌트의 type에 따라 Fiber 노드를 업데이트한 후, child Fiber 노드를 복사해 반환한다
  }
}

`beginWork()` 초반부에 `oldProps`를 `unitOfWork.alternate.memoizedProps`로, `newProps`를 `unitOfWork.pendingProps`로 두고 얕은 비교를 해서 현재 Fiber 노드가 업데이트되어야 하는지 플래그를 설정한다. `React.memo()`를 이용해 컴포넌트를 메모이제이션 할 수 있기 때문에 현재 단계에서는 바로 업데이트를 수행하지 않고 `didReceiveUpdate`로 업데이트가 발생할 수도 있다고 표시만 해둔다.

props도 컨텍스트도 변하지 않았고, 렌더링 중인 Lane이 현재 Fiber 노드의 업데이트 Lane에 존재하지 않는다면(`useState`, `useReducer` 등 상태 업데이트가 예약되지 않은 경우) `attemptEarlyBailoutIfNoScheduledUpdate()`을 실행해 자식 Fiber 노드를 복사하고 현재 Fiber 노드는 넘어간다. 그 외에는 Fiber 노드의 `tag` 속성값에 따라 노드를 업데이트하고 자식 Fiber 노드를 복사해 반환한다. `tag`에는 FunctionComponent, HostRoot(루트 Fiber), Fragment, ForwardRef, MemoComponent, LazyComponent 등이 있는데 전체 목록은 여기에서 확인할 수 있다.

 

 

Q. 개별 Fiber 노드에 memoizedProps, pendingProps 필드가 있는데도 왜 Fiber 트리를 두 개 만들어서 비교하는 걸까?

렌더링 작업은 Fiber 트리를 순회하면서 각각의 Fiber 노드에 다음 상태값을 반영하는 과정이라고 할 수 있다. 이때 Fiber 트리가 하나 뿐이라면 렌더링 작업이 도중에 중단되었을 때 불완전하게 계산된 UI가 화면에 반영되거나 부모와 자식 노드 간에 데이터가 맞지 않아 오류가 발생할 수도 있다. 따라서 concurrent 방식의 렌더링을 구현하기 위해서는 memoizedProps, pendingProps 필드 외에도 두 개의 Fiber 트리를 메모리에 둬야 한다. 

memoizedProps, pendingProps 필드가 단순히 이전, 현재 상태값을 저장하고 둘을 비교하기 위해 존재한다면 두 개의 Fiber 트리는 렌더링 중에도 이전 상태값을 가진 UI가 유지될 수 있도록 스냅샷을 위한 용도이다. 

 

 

Q. 왜 unitOfWork.memoizedProps와 unitOfWork.pendingProps를 비교하지 않고, unitOfWork.alternate.memoizedProps와 unitOfWork.pendingProps를 비교하는 걸까?

`pendingProps`는 렌더링이 진행될 때마다 새롭게 갱신되어 문제가 될 일은 없지만 `memoizedProps`는 UI에 이미 커밋된 상태를 나타내야 하기 때문에 변경하는 시점을 신중하게 결정해야 한다. 각 `workInProgress` Fiber 노드의 `memoizedProps`는 커밋까지 완료되었을 때가 아니라 `beginWork()`가 완료되었을 때 갱신되는데, 하나의 tick 내에 렌더링이 완료되었다면 `beginWork()`가 완료되었을 때 `unitOfWork.pendingProps`를 변경하더라도 문제가 될 일은 없다.

예를 들어, `unitOfWork.memoizedProps.someProp`이 1이었을 때 현재 렌더링에서 `someProp`이 업데이트되지 않았다면 `unitOfWork.pendingProps.someProp`은 그대로 1이고, `someProp`이 2로 변경되었다면 `unitOfWork.pendingProps.someProp`는 2가 되기 때문에 `unitOfWork`의 memoizedProps와 pendingProps를 비교해도 UI 업데이트 여부를 문제없이 판단할 수 있는 것처럼 보인다.

하지만 React는 concurrent 렌더링을 지원하기 때문에 여러 tick에 걸쳐 렌더링이 완료되는 경우에는 문제가 될 수 있다. 특히 A 루트 Fiber에서의 렌더링이 도중에 중단된 후 B 루트 Fiber에서의 렌더링이 진행되었다면, 다시 A 루트 렌더링이 재개될 때 `workInProgress`는 초기화되기 때문에 `unitOfWork`가 루트 Fiber 노드부터 다시 설정된다.(처음부터 다시 순회) Fiber 트리는 mutable 하게 변경되기 때문에 A Fiber 트리에는 ① 업데이트될 상태값이 이미 반영된 Fiber 노드와 ② 아직 렌더링 작업이 이뤄지지 않아 스냅샷의 상태값 그대로인 Fiber 노드가 혼재되어 있다. ①에 해당하는 Fiber 노드는 재렌더링되어야 하는 노드임에도 이전에 이미 렌더링이 되었기 때문에 `unitOfWork.memoizedProps === unitOfWork.pendingProps`이다. 따라서 이전 상태와 다음 상태를 비교할 때는 자신의 memoizedProps와 pendingProps가 아니라 스냅샷인 반대편 트리의 memoizedProps와 자신의 pendingProps를 비교해야 한다. 

예를 들어, 위와 같이 UI에 반영된 어떤 prop 값이 1이고 해당 값이 2로 업데이트 된다고 해보자. `unitOfWork.memoizedProps !== unitOfWork.pendingProps`이므로 이 변경 사항은 커밋되어야 한다. `beginWork()`가 종료되면 `unitOfWork.memoizedProps = unitOfWork.pendingProps`로 변경되므로 Fiber 트리는 아래와 같을 것이다.

이때 이 Fiber 노드를 업데이트한 후 렌더링이 도중에 중단되고, 다른 루트 Fiber에서의 렌더링을 처리하고 다시 현재 루트 Fiber로 돌아와서 렌더링 작업을 한다고 해보자. 루트 Fiber가 달라졌기 때문에 렌더링은 루트부터 다시 시작된다. 하지만 해당 노드에서 다시 `beginWork()`가 실행될 때는 `unitOfWork.memoizedProps`에서 해당 prop은 2이므로 React에서는 변경 사항이 없다고 잘못 판단하게 된다. 따라서 이전 상태값과 비교할 때는 `unitOfWork.memoizedProps`가 아니라 `unitOfWork.alternate.memoizedProps`를 사용해야 한다.

 

 

A. 함수 컴포넌트의 Fiber 노드 업데이트

`beginWork()`의 `switch` 문에서 `workInProgress.tag` 케이스별로 Fiber 노드를 업데이트하는데 그중에서 함수 컴포넌트가 업데이트되는 과정을 살펴보자.

// packages\react-reconciler\src\ReactFiberBeginWork.js
function updateFunctionComponent(/* ... */) {
  let context;
  if (!disableLegacyContext && !disableLegacyContextForFunctionComponents) {
    // Legacy Context를 사용하는 경우 처리
  }
  prepareToReadContext(workInProgress, renderLanes);
  // 1️⃣ Fiber 노드에 대응하는 컴포넌트를 실행해 업데이트된 자식 ReactElement를 얻는다  
  const nextChildren = renderWithHooks(current, workInProgress, Component, nextProps, context, renderLanes);
  const hasId = checkDidRenderIdHook();

  // 2️⃣ 업데이트할 사항이 없다면 자식 Fiber 노드를 복사한 후 반환
  if (current !== null && !didReceiveUpdate) {
    bailoutHooks(current, workInProgress, renderLanes);
    return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
  }
  // 3️⃣ 업데이트된 자식 ReactElement으로 자식 Fiber 노드를 복사(clone)한다
  reconcileChildren(current, workInProgress, nextChildren, renderLanes);
  return workInProgress.child;
}

함수형 컴포넌트 업데이트는 `updateFunctionComponent()`에서 처리되는데 우선 Legacy Context를 사용하는 경우에 대해 `context` 값을 설정한다. React 18+에서는 Legacy Context가 아니라 `createContext()`를 이용해 컨텍스트를 생성하므로 사용하지 않는 값이라고 생각해도 무방하다.

const MyCompnent = () => {
  const [state, setState] = useState(0);
  
  return <p>{state}</p>
}

`renderWithHooks()`에서는 우리가 작성한 컴포넌트 함수(위의 예시에서는 `MyComponent`)를 실행해 그것이 반환하는 ReactElement를 얻는다.(Fiber 노드가 아니다) 보통 함수형 컴포넌트 내부에 `console.log("render")`을 추가해 해당 컴포넌트가 재렌더링되었는지 확인하고는 하는데, 컴포넌트가 호출된다면 해당 Fiber 노드가 `beginWork()`에서 bailout 되지 못한 경우이기 때문에 업데이트 사항이 있을 가능성이 높기 때문이다. 물론 컴포넌트가 재실행되는 건 렌더 단계에서의 얘기일 뿐, 컴포넌트가 재실행된다고 해서 실제 DOM에서도 업데이트가 발생한다고 할 수는 없다. 렌더 단계에서 생성한 Fiber 트리를 커밋 단계에서 비교해서 DOM에 반영할지 결정하기 때문이다.

// packages\react-reconciler\src\ReactFiberHooks.js
export function renderWithHooks<Props, SecondArg>(/* ... */) {
  renderLanes = nextRenderLanes;
  currentlyRenderingFiber = workInProgress;
  ReactSharedInternals.H = current === null || current.memoizedState === null
    ? HooksDispatcherOnMount
    : HooksDispatcherOnUpdate;
    
  // 1️⃣ 함수 컴포넌트를 실행해 ReactElement를 얻는다  
  let children = Component(props, secondArg);
  
  // ✳️ 컴포넌트 본문에서 상태를 업데이트하는 경우 반복적으로 ReactElement를 재생성
  if (didScheduleRenderPhaseUpdateDuringThisPass) {
    children = renderWithHooksAgain(workInProgress, Component, props, secondArg);
  }
  // 2️⃣ hook 렌더링을 정리한다
  finishRenderingHooks(current, workInProgress, Component);

  return children;
}

`renderWithHooks()`에서는 우선 현재 Fiber 노드가 마운트되는지, 업데이트되는지에 따라 `ReactSharedInternals.H`를 설정한다. 이 값은 컴포넌트 본문에서 hook이 실행될 때 참조되기 때문에 상황(마운트/업데이트)에 맞는 값으로 설정되어야 한다.

한편, 컴포넌트에서 상태값이 업데이트되었을 때 그것을 해당 hook에 반영하는 로직은 `renderWithHooks()`에 존재하지 않는다. `renderWithHooks()`에서 `Component(props, secondArg)`으로 컴포넌트를 호출하면 내부에 정의된 hook도 같이 실행되는데 `useState`, `useReducer` 등 개별 hook 내부에서 각각의 변경 사항을 업데이트한다. 이 hook에서 최신 상태값을 반환하면 그 값이 반영된 자식 ReactElement를 얻을 수 있다.

 

 

B. hook.memoizedState 최신화

앞서 상태를 업데이트하는 로직은 hook 내부에 존재한다고 했는데 `useState`에서는 어떤 식으로 업데이트된 상태값을 반영하는지 살펴보자.

// packages\react-reconciler\src\ReactFiberHooks.js
function updateState<S>(initialState: (() => S) | S): [S, Dispatch<BasicStateAction<S>>] {
  return updateReducer(basicStateReducer, initialState);
}

function updateReducer<S, I, A>(reducer: (S, A) => S, initialArg: I, init?: I => S): [S, Dispatch<A>] {
  const hook = updateWorkInProgressHook();
  return updateReducerImpl(hook, currentHook as Hook, reducer);
}

`renderWithHooks()`에서 컴포넌트 내부에 존재하는 `useState`가 호출된다면 `HooksDispatcherOnUpdate.useState` → `updateState` → `updateReducer` `updateReducerImpl` 순으로 실행된다. 특히 `updateReducerImpl`에서는 1-B. Concurrent 큐 처리에서 구성한 `hook.queue`를 차례대로 처리해 `hook.memoizedState`에 반영한다.

// packages\react-reconciler\src\ReactFiberHooks.js
function updateReducerImpl<S, A>(hook: Hook, current: Hook, reducer: (S, A) => S) {
  const queue = hook.queue;
  // ✴️ baseQueue 뒤에 pendingQueue를 이어 붙인 큐를 사용해 업데이트를 처리한다
  let baseQueue = hook.baseQueue;
  const pendingQueue = queue.pending;
  
  /* baseQueue와 pendingQueue를 연결한다 */
  
  if (baseQueue === null) {
    // ...
  } else {
    // ✴️ baseQueue.next로 큐의 첫번째 Udpate 객체를 얻는다
    const first = baseQueue.next;
    let update = first;
    
    do {
       /* 큐의 첫번째 항목에 도달하기 전까지 hook 업데이트 사항을 적용한다 */
    } while (update !== null && update !== first);
  }
}

업데이트 큐로 `hook.queue`와 `hook.baseQueue`가 사용되는 걸 볼 수 있는데 `hook.baseQueue`는 우선순위가 낮은 Update 객체들의 연결 리스트이다. `hook.baseQueue`에 저장된 Update 객체들은 충분한 우선순위를 가지고 있지 않아 직전 렌더 때 반영되지 않았기 때문에 이번 렌더에서 우선순위를 다시 체크해 처리할지 결정한다.

// packages\react-reconciler\src\ReactFiberHooks.js
function updateReducerImpl<S, A>(hook: Hook, current: Hook, reducer: (S, A) => S) {
  /* (생략) */
  } else {
    const first = baseQueue.next;
    let update = first;
    let newState = hook.baseState;
  
    let newBaseQueueFirst = null;
    let newBaseQueueLast: Update<S, A> | null = null;
    
    do {
      // 1️⃣ 현재 Update 객체의 우선순위를 체크해 이번에 반영할지 결정한다
      const updateLane = removeLanes(update.lane, OffscreenLane);
      const isHiddenUpdate = updateLane !== update.lane;
      let shouldSkipUpdate = /* ... */
      
      if (shouldSkipUpdate) {
        // 우선순위가 부족한 Update: Update 객체를 복사해 newBaseQueue에 추가한다
      } else {
        // 2️⃣ 우선순위가 충분한 Update 객체는 이번 렌더에 계산한다
        newState = reducer(newState, update.action);
      }
      // 3️⃣ 다음 Update 객체로 넘어간다
      update = update.next;
    } while (update !== null && update !== first);
    
    // 4️⃣ hook의 최종 상태가 이전과 달라졌다면 didReceiveUpdate 플래그를 true로 설정
    if (!is(newState, hook.memoizedState)) {
      markWorkInProgressReceivedUpdate();
    }
  }
  hook.memoizedState = newState;
  
  return [hook.memoizedState, queue.dispatch] as const;
}

이번 렌더에 반영할 Update 객체로 다음 상태값을 계산한 후 이전 상태인 `hook.memoizedState`와 달라졌다면 전역 플래그 `didReceiveUpdate = true`로 변경해 해당 Fiber 노드가 업데이트될 수 있도록 한다.

 

 

C. 자식 Fiber 노드 복사 및 재조정

`renderWithHooks()`을 실행해 자식 ReactElement를 얻었지만 Fiber 트리에서 필요한 것은 Fiber 노드이지 ReactElement가 아니다. 컨텍스트가 변하지 않았거나 `hook.memoizedState`가 달라지지 않았다면 해당 Fiber 노드를 업데이트할 필요가 없다. 하지만 자식 Fiber 노드에는 변경 사항이 존재할 수 있기 때문에 자식 alternate Fiber 노드를 복사해 자식 노드들에 대해서도 `beginWork()`가 실행될 수 있도록 한다. `updateFunctionComponent()`에서 `renderWithHooks()`가 종료된 후 현재 Fiber 노드 `workInProgress`에 변경 사항이 존재하지 않는다면 `cloneChildFibers()`에서 모든 자식 노드의 alternate 노드를 생성/수정한다.

// packages\react-reconciler\src\ReactFiberBeginWork.js
// updateFunctionComponent()에서...
if (current !== null && !didReceiveUpdate) {
  bailoutHooks(current, workInProgress, renderLanes);
  return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
}
// packages\react-reconciler\src\ReactFiberBeginWork.js
function bailoutOnAlreadyFinishedWork(current: Fiber | null, workInProgress: Fiber, renderLanes: Lanes): Fiber | null {
  if (current !== null) {
    workInProgress.dependencies = current.dependencies;
  }
  markSkippedUpdateLanes(workInProgress.lanes);

  if (!includesSomeLane(renderLanes, workInProgress.childLanes)) {
    // ...
  }
  // ✴️ 자식 Fiber 노드에서 업데이트 사항이 있을 수 있으므로 자식 Fiber 복사 후 반환
  cloneChildFibers(current, workInProgress);
  return workInProgress.child;
}

반면에, 현재 Fiber 노드에 변경 사항이 있는 경우에는 자식 ReactElement로부터 Fiber 노드를 얻어야 하는데 이것을 `reconcileChildren()`에서 처리한다.

// packages\react-reconciler\src\ReactFiberBeginWork.js
export function reconcileChildren(
  current: Fiber | null,
  workInProgress: Fiber,
  nextChildren: any,
  renderLanes: Lanes,
) {
  if (current === null) {
    workInProgress.child = mountChildFibers(workInProgress, null, nextChildren, renderLanes);
  } else {
    workInProgress.child = reconcileChildFibers(workInProgress, current.child, nextChildren, renderLanes);
  }
}

`reconcileChildFibers()`에는 현재 렌더링 중인 Fiber 노드 `workInProgress`, 현재 Fiber 노드의 첫번째 자식 Fiber인 `current.child`, 현재 Fiber 노드에 대응되는 컴포넌트를 실행해서 얻어지는 ReactElement인 `nextChildren`이 인수로 전달된다. 이 값들을 토대로 `reconcileChildFibersImpl()`에서 `nextChildren`의 타입이 텍스트인지, 단일 ReactElement인지, 배열인지 등에 따라 alternate 노드를 생성/수정한다. 예를 들어, 아래 컴포넌트에서 `state`가 `true`로 업데이트되어 `<p>`가 텍스트로 바뀐다고 해보자.

const MyComponent = () => {
  const [state, setState] = useState(false);
  
  return state ? "참" : <p>거짓</p>
}

`reconcileChildFibers()`에 전달되는 `current.child`는 `<p>` Fiber 노드이고, `nextChildren`은 텍스트 `"참"`인 ReactElement이다. `nextChildren`이 텍스트 노드이므로 `typeof newChild === 'string' && newChild !== ''` 조건에 걸려 `reconcileSingleTextNode()`에서 처리된다.

// packages\react-reconciler\src\ReactChildFiber.js
function reconcileChildFibersImpl(
    returnFiber: Fiber,
    currentFirstChild: Fiber | null,
    newChild: any,
    lanes: Lanes,
  ): Fiber | null {
    // ...
    if (typeof newChild === 'string' && newChild !== '') {
      return placeSingleChild(
        reconcileSingleTextNode(
          returnFiber,
          currentFirstChild,
          '' + newChild,
          lanes,
        ),
      );
    }
  }

`reconcileSingleTextNode()`에 전달되는 `returnFiber`는 부모 노드인 `workInProgress`(`MyComponent`에 대응되는 Fiber 노드)이고, `currentFirstChild`는 현재 UI 스냅샷에서의 첫번째 자식 Fiber 노드(`<p>거짓</p>`에 대응되는 Fiber 노드), `textContent`는 이번 렌더링에서 얻어진 텍스트(`"참"`)이다.

// packages\react-reconciler\src\ReactChildFiber.js
function reconcileSingleTextNode(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  textContent: string,
  lanes: Lanes,
): Fiber {
  // 1️⃣ 이전 Fiber 노드가 텍스트인 경우
  if (currentFirstChild !== null && currentFirstChild.tag === HostText) {
    deleteRemainingChildren(returnFiber, currentFirstChild.sibling);
    const existing = useFiber(currentFirstChild, textContent);
    existing.return = returnFiber;
    
    return existing;
  }
  // 2️⃣ 이전 Fiber 노드가 텍스트가 아닌 경우
  deleteRemainingChildren(returnFiber, currentFirstChild);
  const created = createFiberFromText(textContent, returnFiber.mode, lanes);
  created.return = returnFiber;

  return created;
}

`reconcileSingleTextNode()`에서는 현재 예시처럼 자식 스냅샷 Fiber 노드가 텍스트가 아닐 수도 있기 때문에 경우를 나눠서 자식 Fiber 노드를 생성/수정해준다. 이전 자식 Fiber 노드가 동일하게 텍스트 타입이었다면 `useFiber()`를 이용해 `fiber.pendingProps`만 새로 업데이트된 텍스트로 수정해준다. 반면에, 텍스트가 아니었다면 `createFiberFromText()` 내부에서 `createFiber()`를 실행해 `HostText` 타입(DOM TextNode)인 Fiber 노드를 생성해준다.

`reconcileSingleTextNode()`가 실행되는 경우 텍스트 노드는 트리의 가장 말단이 되기 때문에 `deleteRemainingChildren()`에서 텍스트 노드가 아닌 첫번째 자식 Fiber 스냅샷 노드부터 그것의 모든 형제 스냅샷 노드까지 제거해준다.

 

첫번째 자식 스냅샷 노드의 모든 자식 노드가 아니라 모든 "형제" 노드를 삭제하는 이유는 무엇일까? 이번 렌더에서 Fiber 트리를 재구성하기 때문에 개별 노드는 자연스럽게 삭제된다. 하지만 노드를 단순히 Fiber 트리에서 제거한다고 해서 끝이 아니고 `useEffect`의 클린업 함수를 실행하는 등 deletion effect까지 처리되어야 한다. `deleteRemainingChildren()`에서는 커밋 단계에서 deletion effect를 실행할 수 있도록 `parent.deletions`에 삭제할 자식 노드를 수집한다. 어떤 Fiber 노드가 `parent.deletions`에 포함되면 `commitDeletionEffectsOnFiber()`에서 `recursivelyTraverseDeletionEffects()`을 통해 그 자식 노드들에 대해서도 재귀적으로 deletion effect를 실행하기 때문에 자식 노드들은 포함될 필요가 없다. 하지만 형제 노드는 이번 렌더에서 삭제될 때 `parent.deletions`에 넣어주지 않으면 deletion effect를 실행하기 어렵기 때문에 자식 노드가 아니라 형제 노드를 목록에 넣어주는 것이다.

// packages\react-reconciler\src\ReactFiberCommitWork.js
function recursivelyTraverseDeletionEffects(
  finishedRoot: FiberRoot,
  nearestMountedAncestor: Fiber,
  // ✳️ deletions[i]가 parent로서 전달된다
  parent: Fiber,
) {
  let child = parent.child;
  
  while (child !== null) {
    commitDeletionEffectsOnFiber(finishedRoot, nearestMountedAncestor, child);
    child = child.sibling;
  }
}

 

 

 

3. alternate Fiber 노드 변화 과정

Fiber Architecture에서는 두 개의 Fiber 트리를 두고(double buffering) 서로의 변경 사항을 비교해 DOM에 반영한다. 이때 alternate 노드는 루트 Fiber와 그 자식 Fiber 모두 `createWorkInProgress()`를 통해 생성되고 수정된다. `createWorkInProgress()`에 현재 Fiber 노드의 스냅샷 노드를 전달하면 `pendingProps`를 반영해 alternate 노드(workInProgress)를 반환한다.

// packages\react-reconciler\src\ReactFiber.js
function createWorkInProgress(current: Fiber, pendingProps: any): Fiber {
  let workInProgress = current.alternate;

  // 1️⃣ 기존의 alternate 노드가 없을 때: 처음으로 업데이트되는 경우
  if (workInProgress === null) {
    // ❇️ pendingProps를 같이 전달해 Fiber 노드를 새로 생성한다
    workInProgress = createFiber(current.tag, pendingProps, current.key, current.mode);
    workInProgress.elementType = current.elementType;
    workInProgress.type = current.type;
    workInProgress.stateNode = current.stateNode;
    // ❇️ 서로가 alternate 속성을 통해 상대방을 참조할 수 있도록 연결한다
    workInProgress.alternate = current;
    current.alternate = workInProgress;
  // 2️⃣ 기존의 alternate 노드가 있을 때: 기존에 생성한 alternate Fiber를 그대로 사용
  } else {
    workInProgress.pendingProps = pendingProps;
    workInProgress.type = current.type;
    workInProgress.flags = NoFlags;
    workInProgress.subtreeFlags = NoFlags;
    workInProgress.deletions = null;
  }
  // ...
  return workInProgress;
}

이때, 스냅샷 노드와 alternate 노드는 그 역할이 고정된 것이 아니고 매 렌더링마다 번갈아가며 선택되기 때문에 어떤 노드가 항상 UI 스냅샷 상태를 나타내고, 그 노드의 alternate 노드가 화면에 반영될 상태를 나타낸다고 말할 수는 없다.

workInProgress 노드는 매 렌더링마다 번갈아가며 선택된다

 

 

A. 루트 Fiber 노드

루트 Fiber 노드는 `createRoot()`로 처음 생성될 때 `root.current` 속성값으로 할당된다. 이 단계에서는 아직 루트 Fiber의 alternate 노드는 생성되지 않은 상태이다.

function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
  // ...
  if (workInProgressRoot !== root || workInProgressRootRenderLanes !== lanes) {
    prepareFreshStack(root, lanes);
  }
}

function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
  // ...
  // ✴️ root.current로 alternate 노드를 생성/수정한다.(pendingProps는 null)
  const rootWorkInProgress = createWorkInProgress(root.current, null);
  
  workInProgress = rootWorkInProgress;
}

하지만 컴포넌트 트리가 마운트되면 `renderRootConcurrent()`에서 `prepareFreshStack()`가 호출될 때 루트의 alternate 노드가 처음으로 생성된다. 마운트 이후 렌더링 단계에서는 `prepareFreshStack()`가 호출되어 `root.current`로부터 루트 alternate 노드를 생성/수정하고, 이 노드를 `workInProgress`로 사용해 화면에 반영될 Fiber 트리를 만든다.

// packages\react-reconciler\src\ReactFiberWorkLoop.js
function flushMutationEffects() {
  // ...
  // ✴️ root.current를 finishedWork(= root.current.alternate)로 교체한다
  root.current = finishedWork;
}

이후 커밋 단계(Commit Phase)에서 mutation effect 실행이 완료되면 현재 렌더링 단계에서 사용되었던 alternate 루트 Fiber 노드가 `root.current`로 교체된다. 따라서 다음 렌더링에서는 이 노드의 alternate 노드가 `workInProgressRoot`로 사용되고 해당 Fiber 트리에 상태 업데이트가 반영된다.

 

 

B. 변경 사항이 없는 Fiber 노드

function workLoopConcurrentByScheduler() {
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

스냅샷(A), alternate(B) 루트 Fiber 노드는 각각 트리 구조를 이루고 있기 때문에 현재 렌더링에서 B 루트 Fiber 노드가 사용된다면 자식 노드들 또한 B가 루트 노드인 Fiber 트리에서의 노드가 `workInProgress`로 사용된다. 이것은 현재 순회 중인 Fiber 노드에 변경 사항이 있든 없든 동일하다.

// packages\react-reconciler\src\ReactFiberWorkLoop.js
function performUnitOfWork(unitOfWork: Fiber) {
  const current = unitOfWork.alternate;
  // beginWork가 반환하는 Fiber 노드는 child 노드
  const next = beginWork(current, unitOfWork, entangledRenderLanes);

  // ❇️ alternate(unitOfWork) 노드의 memoizedProps를 pendingProps로 교체한다
  unitOfWork.memoizedProps = unitOfWork.pendingProps;
  // ...
}

`performUnitOfWork()` 내부 코드를 보면 `unitOfWork.alternate`가 `current`라는 변수명으로 사용되고 있는데 `unitOfWork`(= `workInProgress`)가 앞으로 화면에 반영될 값을 담고 있는 Fiber 노드임을 생각해보면 `unitOfWork.alternate`가 "현재" 화면을 반영하고 있는 스냅샷 Fiber 노드라는 것을 어렵지 않게 이해할 수 있다.

// beginWork()에서...
const oldProps = workInProgress.alternate.memoizedProps;
const newProps = workInProgress.pendingProps;

`beginWork()`에서 `oldProps`와 `newProps`가 동일하고, 컨텍스트 또한 변하지 않았다면 `attemptEarlyBailoutIfNoScheduledUpdate()`를 실행해 현재 Fiber 노드에 대한 렌더링 작업을 종료한다. 

// packages\react-reconciler\src\ReactFiberBeginWork.js
function attemptEarlyBailoutIfNoScheduledUpdate(current: Fiber, workInProgress: Fiber, renderLanes: Lanes) {
  switch (workInProgress.tag) { /* ... */ }
  return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes)
}

function bailoutOnAlreadyFinishedWork(current: Fiber | null, workInProgress: Fiber, renderLanes: Lanes): Fiber | null {
  // ...
  // ✴️ 그냥 끝내지 않고 자식 Fiber 노드의 alternate 노드를 생성한다 
  cloneChildFibers(current, workInProgress);
  return workInProgress.child;
}

하지만 자식 Fiber 노드에는 변경 사항이 존재할 수 있기 때문에 `bailoutOnAlreadyFinishedWork()`에서 자식 Fiber 노드들의 alternate 노드를 만들어준다. `cloneChildFibers()`에서 루트 Fiber 노드와 비슷한 방식으로 자식들의 alternate 노드가 생성/수정된다. `current.child`로 첫번째 자식 노드를, `current.child.sibling`로 첫번째 자식 노드의 형제 노드들을 모두 순회해 현재 노드의 모든 자식 노드들의 alternate 노드를 생성/수정한다.

// packages\react-reconciler\src\ReactChildFiber.js
function cloneChildFibers(current: Fiber | null, workInProgress: Fiber) {
  if (workInProgress.child === null) {
    return;
  }
  let currentChild = workInProgress.child;
  let newChild = createWorkInProgress(currentChild, currentChild.pendingProps);
  
  workInProgress.child = newChild;
  newChild.return = workInProgress;
  
  while (currentChild.sibling !== null) {
    currentChild = currentChild.sibling;
    newChild = newChild.sibling = createWorkInProgress(currentChild, currentChild.pendingProps);
    newChild.return = workInProgress;
  }
  newChild.sibling = null;
}

자식 alternate 노드를 생성/수정해준 뒤 `beginWork()`가 종료되면 `unitOfWork.memoizedProps = unitOfWork.pendingProps`으로 현재 `unitOfWork`의 `memoizedProps`를 변경된 값으로 업데이트한다. 현재 렌더링에서 사용되는 `unitOfWork`가 다음 렌더링 때는 `current` 노드로서 사용되므로 `memoizedProps`를 스냅샷 상태값을 참조할 수 있게 된다.

 

C. 변경 사항이 있는 Fiber 노드

변경 사항이 없는 Fiber 노드에서는 `beginWork()`가 early return 되었지만 변경 사항이 있는 Fiber 노드에서는 컴포넌트 타입별로 update 함수를 호출해 자식 Fiber 노드를 생성한다. 여기에서는 함수형 컴포넌트인 경우에 대해서만 살펴보도록 하자.

// packages\react-reconciler\src\ReactFiberBeginWork.js
function beginWork(current: Fiber | null, workInProgress: Fiber, renderLanes: Lanes): Fiber | null {
  // ...
  switch (workInProgress.tag) {
    case FunctionComponent: {
      const Component = workInProgress.type;
      const resolvedProps = workInProgress.pendingProps;
      
      return updateFunctionComponent(current, workInProgress, Component, resolvedProps, renderLanes);
    }
    // ...
  }
}

함수형 컴포넌트를 업데이트하는 `updateFunctionComponent()`에서는 크게 두 가지 역할을 수행한다. 변경된 Fiber 노드로부터 대응되는 컴포넌트(함수)를 실행해 ReactElement를 얻고, 이 ReactElement로부터 자식 Fiber 노드를 생성한다.  

// packages\react-reconciler\src\ReactFiberBeginWork.js
function updateFunctionComponent(current: null | Fiber, workInProgress: Fiber, Component: any, nextProps: any, renderLanes: Lanes) {
  // ...
  // 1️⃣ 함수형 컴포넌트를 호출해 자식 ReactElement를 생성한다
  const nextChildren = renderWithHooks(current, workInProgress, Component, nextProps, context, renderLanes);

  // ...
  // 2️⃣ 생성된 ReactElement로부터 자식 Fiber 노드를 생성한다
  reconcileChildren(current, workInProgress, nextChildren, renderLanes);
  
  return workInProgress.child;
}
// packages\react-reconciler\src\ReactFiberHooks.js
function renderWithHooks(/* ... */) {
  workInProgress.memoizedState = null;
  workInProgress.updateQueue = null;
  workInProgress.lanes = NoLanes;
  // ...
  // ❇️ 함수가 반환하는 children은 함수형 컴포넌트를 실행했을 때 얻어지는 ReactElement이다
  const children = Component(props, secondArg);
  finishRenderingHooks(current, workInProgress, Component);

  return children;
}

`Component()`를 실행하면 내부 hook(`useState` 등)의 업데이트된 상태가 반영된 자식 ReactElement가 반환되는데 `reconcileChildren()`에서 `createFiberFromElement()`를 통해 자식 Fiber 노드들의 최종적인 `unitOfWork.pendingProps`를 업데이트한다.

// packages\react-reconciler\src\ReactFiber.js
export function createFiberFromElement(element: ReactElement, mode: TypeOfMode, lanes: Lanes): Fiber {
  let owner = null;
  const type = element.type;
  const key = element.key;
  const pendingProps = element.props;
  const fiber = createFiberFromTypeAndProps(type, key, pendingProps, owner, mode, lanes);

  return fiber;
}

 

 

더보기

여러 tick에 걸쳐 렌더링 작업이 진행되는 경우 어떻게 다음 tick에 렌더링 작업을 예약할 수 있는 걸까? 우선 하나의 tick에 렌더링이 완료되는 경우에 대해 살펴보고 다음에 여러 tick에 걸쳐 렌더링이 진행되는 경우를 살펴보도록 하자.

 

 

A. 하나의 tick에 렌더링이 모두 종료된 경우

function workLoopConcurrentByScheduler() {
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

`completeUnitOfWork()`에서 루트 Fiber 노드에 도달하게 되면 `performUnitOfWork()`가 종료된다. 스케줄러에 의해 중단되기 전에 모든 작업이 끝나 렌더링이 종료되었기 때문에 `shouldYield() === false`이고 `workInProgress === null`이므로 `workLoopConcurrentByScheduler()`가 종료된다.

function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
  outer: do {
    try {
      // ...
      workLoopConcurrentByScheduler();
      // ✴️ 여기서부터 다시 재개된다! 
      break;
    }  catch (thrownValue) {
      handleThrow(root, thrownValue);
    }
  } while (true);
  
  if (workInProgress !== null) {
    // ...
    // 1️⃣ 렌더링이 종료되었기 때문에 else 구문이 실행된다.
  } else {
    workInProgressRoot = null;
    workInProgressRootRenderLanes = NoLanes;
    
    // 2️⃣ "RootCompleted"가 반환된다.
    return workInProgressRootExitStatus;
  }
}

렌더 시작 단계에서 `workLoopConcurrentByScheduler()`가 어디에서 호출되었는지 곱씹어보자. 이 함수가 종료되면 `renderRootConcurrent()`에서의 `while` 루프까지 종료된다. `workInProgress === null`이므로 작업 진행 상황 추적에 사용되는 변수들 `workInProgressRoot = null`, `workInProgressRootRenderLanes = NoLanes`, `workInProgressRootExitStatus = RootCompleted`로 바뀌게 된다.

export function performWorkOnRoot(/* ... */) {
  let exitStatus = renderRootConcurrent(root, lanes);
  // ✴️ 여기서부터 다시 재개된다! 
  do {
    if (exitStatus === RootInProgress) {
      // ...
      // 1️⃣ "RootCompleted"이므로 else 구문이 실행된다.
    } else {
      finishConcurrentRender(
        root,
        exitStatus,
        finishedWork,
        lanes,
        renderEndTime,
      );
    }
    break;
  } while (true);
  
  ensureRootIsScheduled(root);
}

`renderRootConcurrent()` 또한 종료되었으므로 `performWorkOnRoot()` 다음 부분이 이어서 실행된다. `finishConcurrentRender()`가 실행되면 다음 단계인 커밋 단계(Commit Phase)가 진행된다. 하지만 현재 우리의 관심사는 어떻게 다음 tick에 렌더링 작업을 예약할 수 있는가이므로 커밋 단계에 대해서는 살펴보지 않는다.

// packages\react-reconciler\src\ReactFiberWorkLoop.js
function finishConcurrentRender(/* ... */) {
  switch (exitStatus) {
    // 1️⃣ "RootCompleted"이므로 switch 구문에서는 아무런 작업도 하지 않는다.
    case RootSuspended:
    case RootCompleted: {
      break;
    }
  }
  
  // 2️⃣ 커밋 단계(Commit Phase)로 넘어간다.
  commitRootWhenReady(/* ... */)
}

커밋 단계까지 완료되었다면 `ensureRootIsScheduled()`를 실행해 현재 Fiber 루트 노드에 대한 다음 작업을 예약한다. 방금 완료된 렌더링, 커밋 작업은 현재 루트 Fiber 노드의 단일 Lane에 대해서만 진행된 것이다. 다른 Lane, 루트 Fiber 노드에 작업이 남아있다면 해당 작업들도 모두 처리해야 하기 때문에 마지막에 `ensureRootIsScheduled()`를 실행하는 것이다.

이렇게 `ensureRootIsScheduled()`이 실행되면 React Fiber Architecture: ① 작업 스케줄링에서 살펴본 대로 `ensureRootIsScheduled()` → `ensureScheduleIsScheduled()` `scheduleImmediateRootScheduleTask()` → `processRootScheduleInMicrotask()`(Microtask로서 실행됨) 순으로 다시 작업 스케줄링이 시작된다.

// packages\react-reconciler\src\ReactFiberRootScheduler.js
function scheduleTaskForRootDuringMicrotask(
  root: FiberRoot,
  currentTime: number,
): Lane {
  // ✳️ workInProgressRoot === null이다.
  const workInProgressRoot = getWorkInProgressRoot();
  // ✳️ 현재 root에서 가장 우선순위가 높은 Lane을 선택해 반환한다.
  const nextLanes = getNextLanes(
    root,
    root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes,
    rootHasPendingCommit,
  );
  
  // ❕ 더이상 작업할 Lane이 없다면 NoLane을 반환한다.
  if (nextLanes === NoLanes || ...) {
    root.callbackNode = null;
    root.callbackPriority = NoLane;
    
    return NoLane;
  }
  
  // ...
  // ❗ 더이상의 처리할 작업이 없다면 스케줄러에 작업이 예약되지 않는다.
  const newCallbackNode = scheduleCallback(
    schedulerPriorityLevel,
    performWorkOnRootViaSchedulerTask.bind(null, root),
  );
}

`processRootScheduleInMicrotask()` 내부에서 실행되는 `scheduleTaskForRootDuringMicrotask()`는 다음에 작업해야 할 Lane을 찾아서 반환한다. 현재 루트 Fiber 노드에 이어서 처리해야 할 작업이 없다면 `performWorkOnRootViaSchedulerTask.bind(null, root)`가 예약되지 않기 때문에 렌더링은 더이상 실행되지 않고 종료된다. 하지만, 작업할 다른 Lane이 남아있다면 `scheduleCallback()`가 실행되어 해당 Lane에 대한 작업이 예약된다.

function processRootScheduleInMicrotask() {
  let root = firstScheduledRoot;
  
  while (root !== null) {
    const next = root.next;
    // 1️⃣ 더이상 작업이 없다면 nextLanes === NoLane이다.
    const nextLanes = scheduleTaskForRootDuringMicrotask(root, currentTime);
    
    // 2️⃣ 업데이트 작업 큐 재구성
    if (nextLanes === NoLane) { /* ... */ }
  }
  // 3️⃣ 처리해야 할 Commit Effect가 없다면 sync work를 처리
  if (!hasPendingCommitEffects()) {
    flushSyncWorkAcrossRoots_impl(syncTransitionLanes, false);
  }
}

렌더 완료 단계까지 끝났을 때 Passive effects(`useEffect`)가 아직 처리되지 않았기 때문에 일반적인 경우에는 `flushSyncWorkAcrossRoots_impl()`가 실행되지 않는다.

 

 

B. 스케줄러에 의해 렌더링이 중단된 경우

이번에는 전체 렌더링 작업이 16ms보다 더 많이 소요되어 렌더링 작업을 끝마치기 전에 스케줄러에 의해 작업이 중단된 경우이다. A Fiber Node의 작업이 완료되고 다음 Fiber Node에 대한 `performUnitOfWork()`를 실행하기 전에 if 문의 조건을 검사하는데, `shouldYield() === true`이므로 `performUnitOfWork()`이 더이상 실행되지 않고 `workLoopConcurrentByScheduler()`이 종료된다. 하지만 A. 하나의 tick에 렌더링이 모두 종료된 경우와 다른 점은 `workInProgress !== null`라는 것이다.

function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
  outer: do {
    try {
      // ...
      workLoopConcurrentByScheduler();
      // ✴️ 여기서부터 다시 재개된다! 
      break;
    }  catch (thrownValue) {
      handleThrow(root, thrownValue);
    }
  } while (true);
  
  if (workInProgress !== null) {
    // 1️⃣ "RootInProgress"가 반환된다.
    return RootInProgress;
  } else {
    // ...
  }
}

`workInProgressRoot`, `workInProgressRootRenderLanes`, `workInProgressRootExitStatus`는 변하지 않고 마지막으로 실행된 작업에서의 값을 유지한다.

export function performWorkOnRoot(/* ... */) {
  let exitStatus = renderRootConcurrent(root, lanes);
  // ✴️ 여기서부터 다시 재개된다! 
  do {
    // 1️⃣ "RootInProgress"이므로 if 구문이 실행된다.
    if (exitStatus === RootInProgress) {
      // prerendering은 아니라고 가정한다
      if (workInProgressRootIsPrerendering && !shouldTimeSlice) { /* ... */ }
    } else {
      // ...
    }
    break;
  } while (true);
  
  // 2️⃣ 다음 작업을 예약한다.
  ensureRootIsScheduled(root);
}

 `ensureRootIsScheduled()`를 실행해 현재 Fiber 루트 노드에 대한 다음 작업을 예약한다. 현재 작업이 도중에 중단됐기 때문에 남은 작업은 Microtask로 예약되어 다음 tick에 실행된다.

 

※ 렌더링이 도중에 중단된 경우, UI에는 이전 상태값 유지된다. Fiber 트리는 스냅샷 트리, 아직 화면에 반영되지 않은 상태를 담고 있는 트리 두 개로 구성되어 있기 때문에 렌더링이 중단되었다 하더라도 작업이 완료된 일부만 화면에 커밋하지는 않는다.

 

 

C. Suspense 내부에서 렌더링이 준비되지 않은 경우

 

 

 

D. 렌더링 중 오류가 발생한 경우

 

 

 

 

 

 

 

 

 

 

 

[참고]

React 톺아보기 - 03. Hooks_1