[번역] React 동시성 모드에서 Suspense는 어떻게 동작하는가 1 - Reconciling flow

[번역] React 동시성 모드에서 Suspense는 어떻게 동작하는가 1 - Reconciling flow

React Internals Deep Dive - EP22

·

13 min read

영문 블로그 글을 번역했습니다. 허가를 받으면 시리즈를 이어갈 예정입니다.
원문링크: https://jser.dev/react/2022/04/02/suspense-in-concurrent-mode-1-reconciling


ℹ️ React Internals Deep Dive 에피소드 22, 유튜브에서 제가 설명하는 것을 시청해주세요. 💬 역자 주석: 에피소드가 건너뛰게 된 건 시리즈 순서상 7번째가 맞지만 에피소드는 22이기 때문입니다. 원 글의 순서대로 진행하는 중입니다.

React@18.2.0 기준, 최신 버전에서는 구현이 변경되었을 수 있습니다.

제가 Suspense가 어떻게 작동하는지 알아내려고 노력한 적이 있는데, 유튜브 동영상을 보시면 아시겠지만 매우 거칠고 React 18의 최신 로직도 반영하지 못했습니다.

이제 서스펜스가 동시성 모드에서 어떻게 작동하는지 좀 더 자세히 살펴보겠습니다. 매우 복잡함으로 다음 단계에 걸쳐 몇 개의 에피소드로 나누어 설명할 계획입니다.

  1. 조정(reconciling) - Suspense가 조정하는 방법

  2. Offscreen 컴포넌트 - Suspense 컴포넌트가 사용하는 내부 컴포넌트

  3. Suspense Context - ??

  4. Ping & Retry - Promise가 해결된 후 멋지게 리-렌더링하세요.

이 에피소드는 1은 조정(reconciling)에 관한 내용입니다.

Susponse 데모

기초 Suspense 데모를 열어보세요.

코드는 매우 간단하며, 데이터가 준비되지 않았을 때 Promise를 던지는 기본적인 구현일 뿐입니다.

const getResource = (data, delay = 1000) => ({
  _data: null,
  _promise: null,
  status: "pending",
  get data() {
    if (this.status === "ready") {
      return this._data;
    } else {
      if (this._promise == null) {
        this._promise = new Promise((resolve) => {
          setTimeout(() => {
            this._data = data;
            this.status = "ready";
            resolve();
          }, delay);
        });
      }
      throw this._promise;
    }
  },
});
function App() {
  const [resource, setResource] = React.useState(null);
  return (
    <div className="app">
      <button
        onClick={() => {
          setResource(getResource("JSer"));
        }}
      >
        start
      </button>
      <React.Suspense fallback={<p>loading...</p>}>
        <Child resource={resource} />
      </React.Suspense>
    </div>
  );
}

예상대로, 리소스가 로딩중일 때 Fallback이 표시됩니다.

먼저 Suspense 컴포넌트가 어떻게 렌더링 되는지 살펴봅시다

beginWork()내에 있는 이 코드 조각을 찾을 수 있습니다. 소스

case SuspenseComponent:
  return updateSuspenseComponent(current, workInProgress, renderLanes);

Suspense의 최초 렌더링과 업데이트가 모두 updateSuspenseComponent에 있다는 의미로, 거대한 코드 소스 덩어리(chunk)인데, 이를 분석해 보겠습니다.

function updateSuspenseComponent(current, workInProgress, renderLanes) {
  const nextProps = workInProgress.pendingProps;
  let suspenseContext: SuspenseContext = suspenseStackCursor.current;
  let showFallback = false;
  const didSuspend = (workInProgress.flags & DidCapture) !== NoFlags;
  if (
    didSuspend ||
    shouldRemainOnFallback(suspenseContext, current, workInProgress, renderLanes)
  ) {
    // Something in this boundary's subtree already suspended. Switch to
    // rendering the fallback children.
    showFallback = true;
    workInProgress.flags &= ~DidCapture;
  }

먼저 SuspenseContext가 무엇인지, 이 부분은 다음 에피소드(Suspense 2편)에서 알아볼 예정입니다. 지금은 건너뛰겠습니다.

showFallback은 매우 간단하며, 폴백 표시 여부를 결정하는 변수이며 기본값은 false입니다.

showFallbackDidSuspend에 의존하고, 다시 DidCapture에 의존하는 것을 볼 수 있으며, 이는 매우 중요한 플래그이므로 유의 해주세요.

shouldRemainOnFallback()은 Suspense Context와 관련된 것이므로 다음 에피소드에서 다루겠습니다.

향후 리-렌더링에서 올바른 콘텐츠를 얻기 위해 DidCapture가 제거되었음을 알 수 있으며, 이는 또한 Promise가 다시 throw됐음을 의미합니다. (이 데모를 사용해 보세요)

최초 마운트

if (current === null) {
  const nextPrimaryChildren = nextProps.children;
  const nextFallbackChildren = nextProps.fallback;
  if (showFallback) {
    const fallbackFragment = mountSuspenseFallbackChildren(
      workInProgress,
      nextPrimaryChildren,
      nextFallbackChildren,
      renderLanes
    );
    const primaryChildFragment: Fiber = (workInProgress.child: any);
    primaryChildFragment.memoizedState =
      mountSuspenseOffscreenState(renderLanes);
    workInProgress.memoizedState = SUSPENDED_MARKER;
    return fallbackFragment;
  } else {
    return mountSuspensePrimaryChildren(
      workInProgress,
      nextPrimaryChildren,
      renderLanes
    );
  }
}

current === null은 최초 렌더링을 의미합니다. mountSuspenseFallbackChildren()은 primary children(content)과 폴백을 모두 마운트하지만 폴백 프래그먼트를 반환합니다.

memoizedState도 초기화되며, 이는 이 Suspense가 폴백을 렌더링하고 있음을 나타내는 마커입니다.

폴백을 렌더링하지 않는 경우, mountSuspenseFallbackChildren()이 자식을 마운트합니다.

이 에피소드의 뒷부분에서 mountSuspenseFallbackChildren()mountSuspensePrimaryChildren()에 대해 다시 살펴보겠습니다.

업데이트

그리고 업데이트의 경우, 로직은 실제로 비슷하며, 현재 상태와 상태에 따라 네 가지 분기가 있습니다. 이에 대해 자세히 다룰 것입니다.

} else {
  // This is an update.
  // If the current fiber has a SuspenseState, that means it's already showing
  // a fallback.
  const prevState: null | SuspenseState = current.memoizedState;
  if (prevState !== null) {
    // The current tree is already showing a fallback
    if (showFallback) {
      // prev: fallback, now: fallback
      ...
    } else {
      // prev: fallback, now: content
      ...
    }
  } else {
    if (showFallback) {
     // prev: content, now: callback
     ...
    } else {
      // prev: content, now: content
      ...
    }
  }
}

prev: fallback, now: fallback

prev: fallback, now: fallback#
const nextFallbackChildren = nextProps.fallback;
const nextPrimaryChildren = nextProps.children;
const fallbackChildFragment = updateSuspenseFallbackChildren(
  current,
  workInProgress,
  nextPrimaryChildren,
  nextFallbackChildren,
  renderLanes
);
const primaryChildFragment: Fiber = (workInProgress.child: any);
const prevOffscreenState: OffscreenState | null = (current.child: any)
  .memoizedState;
primaryChildFragment.memoizedState =
  prevOffscreenState === null
    ? mountSuspenseOffscreenState(renderLanes)
    : updateSuspenseOffscreenState(prevOffscreenState, renderLanes);
primaryChildFragment.childLanes = getRemainingWorkInPrimaryTree(
  current,
  renderLanes
);
workInProgress.memoizedState = SUSPENDED_MARKER;
return fallbackChildFragment;

둘 다 폴백을 렌더링하지만 폴백 자체가 변경될 수 있습니다. updateSuspenseFallbackChildren()이 조정(reconciling)합니다. OffscreenState 부분은 약간 혼란스러운데, Suspense Cache와 관련이 있으므로 다음 편을 위해 남겨 두겠습니다.

prev: fallback, now: content

const nextPrimaryChildren = nextProps.children;
const primaryChildFragment = updateSuspensePrimaryChildren(
  current,
  workInProgress,
  nextPrimaryChildren,
  renderLanes
);
workInProgress.memoizedState = null;
return primaryChildFragment;

여기는 간단히, 그냥 자식 파트를 조정(reconcile)합니다.

prev: content, now: callback

코드가 prev: fallback, now: content 과 비슷합니다. 넘어갑니다.

prev: content, now: content

코드가 prev: fallback, now: content 과 비슷합니다.

Suspense 내부 래퍼(Wrapper)

Suspense 컴포넌트는 단순한 컴포넌트가 아니라 Offscreen 컴포넌트 같은 것으로 자식들을 감싸서 멋진 무언가를 만들어냅니다.

지금부터 Offscreen에 대해 간략하게 살펴보고 향후 에피소드에서 자세히 알아보겠습니다.

mountSuspenseFallbackChildren()

좋습니다, 이제부터 mountSuspenseFallbackChildren()에 실제로 무슨 일이 생기는지 봐봅시다.

function mountSuspenseFallbackChildren(
  workInProgress,
  primaryChildren,
  fallbackChildren,
  renderLanes
) {
  const mode = workInProgress.mode;
  const progressedPrimaryFragment: Fiber | null = workInProgress.child;
  const primaryChildProps: OffscreenProps = {
    mode: "hidden",
    children: primaryChildren,
  };
  let primaryChildFragment;
  let fallbackChildFragment;
  primaryChildFragment = mountWorkInProgressOffscreenFiber(
    primaryChildProps,
    mode,
    NoLanes
  );
  fallbackChildFragment = createFiberFromFragment(
    fallbackChildren,
    mode,
    renderLanes,
    null
  );
  primaryChildFragment.return = workInProgress;
  fallbackChildFragment.return = workInProgress;
  primaryChildFragment.sibling = fallbackChildFragment;
  workInProgress.child = primaryChildFragment;
  return fallbackChildFragment;
}
  1. primary child가 Offscreen Fiber로 래핑되고 모드가 hidden으로 설정됨

  2. 폴백은 Fragment로 래핑됩니다.

  3. primary child와 폴백은 모두 자식으로 배치됩니다.

왜 폴백을 프래그먼트로 감싸는 걸까요?

제가 추측하기로 폴백은 ReactNodeList의 타입이며 숫자나 문자열일 수 있고 일반적으로 문자열은 특별한 처리를 해야 하기 때문에 Fragment로 감싸는 것이 처리하기 쉬워서 일 것 입니다.

export type ReactNode =
  | React$Element<any>
  | ReactPortal
  | ReactText
  | ReactFragment
  | ReactProvider<any>
  | ReactConsumer<any>;
export type ReactEmpty = null | void | boolean;
export type ReactFragment = ReactEmpty | Iterable<React$Node>;
export type ReactNodeList = ReactEmpty | React$Node;
export type ReactText = string | number;

자, 여기 Suspense에 대한 파이버 구조 다이어그램입니다.

mountWorkInProgressOffscreenFiber의 특별한 점이 무엇일까요?

function mountWorkInProgressOffscreenFiber(
  offscreenProps: OffscreenProps,
  mode: TypeOfMode,
  renderLanes: Lanes
) {
  // The props argument to `createFiberFromOffscreen` is `any` typed, so we use
  // this wrapper function to constrain it.
  return createFiberFromOffscreen(offscreenProps, mode, NoLanes, null);
}
export function createFiberFromOffscreen(
  pendingProps: OffscreenProps,
  mode: TypeOfMode,
  lanes: Lanes,
  key: null | string
) {
  const fiber = createFiber(OffscreenComponent, pendingProps, key, mode);
  fiber.elementType = REACT_OFFSCREEN_TYPE;
  fiber.lanes = lanes;
  const primaryChildInstance: OffscreenInstance = {};
  fiber.stateNode = primaryChildInstance;
  return fiber;
}

화려하진 않지만, mode 속성을 통해 hidden 또는 visible 여부를 표시할 수 있습니다.

mountSuspensePrimaryChildren()

function mountSuspensePrimaryChildren(
  workInProgress,
  primaryChildren,
  renderLanes
) {
  const mode = workInProgress.mode;
  const primaryChildProps: OffscreenProps = {
    mode: "visible",
    children: primaryChildren,
  };
  const primaryChildFragment = mountWorkInProgressOffscreenFiber(
    primaryChildProps,
    mode,
    renderLanes
  );
  primaryChildFragment.return = workInProgress;
  workInProgress.child = primaryChildFragment;
  return primaryChildFragment;
}

여기에서도 Offscreen Fiber를 사용하지만 이번에는 폴백이 없고 모드가 "visible"입니다.

참고로 workInProgressmode를 가졌지만, 다른 모드인 TypeOfMode가 있습니다.

export type TypeOfMode = number;
export const NoMode = /*                         */ 0b000000;
// TODO: Remove ConcurrentMode by reading from the root tag instead
export const ConcurrentMode = /*                 */ 0b000001;
export const ProfileMode = /*                    */ 0b000010;
export const DebugTracingMode = /*               */ 0b000100;
export const StrictLegacyMode = /*               */ 0b001000;
export const StrictEffectsMode = /*              */ 0b010000;
export const ConcurrentUpdatesByDefaultMode = /* */ 0b100000;

왜 primary children을 파이버0 트리에 남겨두는지 궁금할 수 있습니다. 왜 그냥 제거하지 않을까요? 훌륭한 질문입니다, 간단히 말하자면 파이버의 state를 유지하기 위해서이며, 폴백에서 다시 전환한 후 모든 것이 새것으로 바뀌는 것을 원하지 않기 때문입니다. 자세한 내용은 다음 Offscreen 에피소드에서 확인할 수 있습니다.

이제 Promise가 어떻게 작동하는지 알아보겠습니다.

Suspense에서 Promise가 어떻게 잡히고 업데이트가 트리거 되나요?

우리는 이미 에러 처리의 일부인 promise가 throw 될 때 서스펜스가 반응한다는 것을 알고 있으므로 먼저 handleError로 가보겠습니다. (소스)

function handleError(root, thrownValue): void {
  do {
    let erroredWork = workInProgress;
    try {
      // Reset module-level state that was set during the render phase.
      resetContextDependencies();
      resetHooksAfterThrow();
      // TODO: I found and added this missing line while investigating a
      // separate issue. Write a regression test using string refs.
      ReactCurrentOwner.current = null;
      throwException(
        root,
        erroredWork.return,
        erroredWork,
        thrownValue,
        workInProgressRootRenderLanes
      );
      completeUnitOfWork(erroredWork);
    } catch (yetAnotherThrownValue) {
      // Something in the return path also threw.
      thrownValue = yetAnotherThrownValue;
      if (workInProgress === erroredWork && erroredWork !== null) {
        // If this boundary has already errored, then we had trouble processing
        // the error. Bubble it to the next boundary.
        erroredWork = erroredWork.return;
        workInProgress = erroredWork;
      } else {
        erroredWork = workInProgress;
      }
      continue;
    }
    // Return to the normal work loop.
    return;
  } while (true);
}

따라서 핵심 부분은 다음 두 가지 함수 호출입니다.

  1. throwException

  2. completeUnitOfWork

throwException

소스

거대한 코드 덩어리이므로 세분화해 보겠습니다. 먼저 던지는 파이버는 Incomplete로 표시됩니다.

// The source fiber did not complete.
sourceFiber.flags |= Incomplete;

그런 다음 오류가 then 호출이 가능한지 확인하고, 만약 then 호출이 가능하면 컴포넌트가 일시 중단됩니다.

if (
  value !== null &&
  typeof value === 'object' &&
  typeof value.then === 'function'
) {
  // This is a wakeable. The component suspended.
  const wakeable: Wakeable = (value: any);
  ...
} else {
  // regular error
}

wakeable 은 그냥 던져지는 Promise라고 생각하면 됩니다. Promise가 아니라면 Error Boundary에서 처리해야 하는 일반 에러일 뿐입니다(ErrorBoundary에 대한 동영상 보기).

이제 Suspense 브랜치에 집중해 보겠습니다.

// Schedule the nearest Suspense to re-render the timed out view.
const suspenseBoundary = getNearestSuspenseBoundaryToCapture(returnFiber);

먼저 가장 가까운 Suspense를 가져옵니다. Suspense Boundary라고 부르는데, Error Boundary와 매우 유사하다는 것을 알 수 있습니다.

getNearestSuspenseBoundaryToCapturereturn을 보고 조상 파이버 노드를 재귀적으로 역추적하는 간단한 함수이므로 생략하겠습니다. 소스.

if (suspenseBoundary !== null) {
  suspenseBoundary.flags &= ~ForceClientRender;
  markSuspenseBoundaryShouldCapture(
    suspenseBoundary,
    returnFiber,
    sourceFiber,
    root,
    rootRenderLanes
  );
  // We only attach ping listeners in concurrent mode. Legacy Suspense always
  // commits fallbacks synchronously, so there are no pings.
  if (suspenseBoundary.mode & ConcurrentMode) {
    attachPingListener(root, wakeable, rootRenderLanes);
  }
  attachRetryListener(suspenseBoundary, root, wakeable, rootRenderLanes);
  return;
}

Suspense Boundary를 찾은 후에, 우리가 할 것은 3가지 입니다

  1. markSuspenseBoundaryShouldCapture()

  2. attachPingListener()

  3. attachRetryListener()

분명히 markSuspenseBoundaryShouldCapture() 는 Suspense가 폴백을 렌더링하기 위한 것이고, 나머지 두 개는 어떻게든 콜백을 프로미스에 첨부(attach)하는 것인데, 왜냐하면 폴백들이 안정(setteled)되면 content를 렌더링해야 하기 때문입니다.

2와 3에 대해서는 향후 Ping & Retry 에피소드에서 자세히 설명할 예정입니다.

Suspense를 찾지 못하면 어떻게 할까요?

코드를 계속 진행하면 SyncLane이 아니라면 Suspense Boundary가 없어도 괜찮다는 것을 알 수 있습니다.

else {
  // No boundary was found. Unless this is a sync update, this is OK.
  // We can suspend and wait for more data to arrive.
  if (!includesSyncLane(rootRenderLanes)) {
    // This is not a sync update. Suspend. Since we're not activating a
    // Suspense boundary, this will unwind all the way to the root without
    // performing a second pass to render a fallback. (This is arguably how
    // refresh transitions should work, too, since we're not going to commit
    // the fallbacks anyway.)
    //
    // This case also applies to initial hydration.
    attachPingListener(root, wakeable, rootRenderLanes);
    renderDidSuspendDelayIfPossible();
    return;
  }
  // This is a sync/discrete update. We treat this case like an error
  // because discrete renders are expected to produce a complete tree
  // synchronously to maintain consistency with external state.
  const uncaughtSuspenseError = new Error(
    "A component suspended while responding to synchronous input. This " +
      "will cause the UI to be replaced with a loading indicator. To " +
      "fix, updates that suspend should be wrapped " +
      "with startTransition."
  );
  // If we're outside a transition, fall through to the regular error path.
  // The error will be caught by the nearest suspense boundary.
  value = uncaughtSuspenseError;
}

간단히 말해, 서스펜스가 사용자 행동(action)으로 인해 발생하는 것이라면, Suspense Boundary가 있어야만 합니다.

사용자 행동(action)이 아니거나 전환(transition) 중이면 attachPingListener()renderDidSuspendDelayIfPossible() 이 복구를 시도합니다.

다음은 전환(transition)을 사용하지만 Suspense Boundary가 없는 데모로, 여전히 작동하는 것을 확인할 수 있습니다.

markSuspenseBoundaryShouldCapture()

소스

markSuspenseBoundaryShouldCapture()에서는 동시(Concurrent) 모드 이전에 사용된 Legacy Suspense를 처리하는데, 이는 제가 이전에 만난 버전이므로 무시하고 동시 모드에만 집중해 보겠습니다.

suspenseBoundary.flags |= ShouldCapture;

ShouldCapture가 여기에 설정되어 있으면 DidCapture로 변환되는 단계가 있을 것입니다. 잠시만 기다려 보겠습니다.

sourceFiber.flags |= ForceUpdateForLegacySuspense;
// We're going to commit this fiber even though it didn't complete.
// But we shouldn't call any lifecycle methods or callbacks. Remove
// all lifecycle effect tags.
sourceFiber.flags &= ~(LifecycleEffectMask | Incomplete);

source 파이버의 경우 이미 Incomplete로 표시했지만 여기서는 플래그가 제거되었습니다.

export const LifecycleEffectMask =
  Passive | Update | Callback | Ref | Snapshot | StoreConsistency;

LifecycleEffectMask에는 모든 부수 효과(side effects)가 포함되어 있습니다.

실제로는 완료되지 않은 가짜 완료로 취급하고 있습니다.

// The source fiber did not complete. Mark it with Sync priority to
// indicate that it still has pending work.
sourceFiber.lanes = mergeLanes(sourceFiber.lanes, SyncLane);

이는 Suspense 렌더링 시 DidCapture를 제거하는 것과 관련이 있습니다. 리-렌더링할 때 오류가 발생한 컴포넌트가 리-렌더링되도록 하려면 lanes를 설정하여 bailout을 피해야 합니다.

그런 다음 completeUnitOfWork(erroredWork)로 이동합니다.

completeUnitOfWork

throwException()이 완료된 후 completeUnitOfWork()가 호출됩니다. (소스)

서스펜스에서는 작업이 Incomplete이므로 Incomplete 브랜치만 살펴보겠습니다.

function completeUnitOfWork(unitOfWork: Fiber): void {
  // Attempt to complete the current unit of work, then move to the next
  // sibling. If there are no more siblings, return to the parent fiber.
  let completedWork = unitOfWork;
  do {
    // The current, flushed, state of this fiber is the alternate. Ideally
    // nothing should rely on this, but relying on it here means that we don't
    // need an additional field on the work in progress.
    const current = completedWork.alternate;
    const returnFiber = completedWork.return;
    // Check if the work completed or if something threw.
    if ((completedWork.flags & Incomplete) === NoFlags) {
      ...
    } else {
      // This fiber did not complete because something threw. Pop values off
      // the stack without entering the complete phase. If this is a boundary,
      // capture values if possible.
      const next = unwindWork(current, completedWork, subtreeRenderLanes);
      // Because this fiber did not complete, don't reset its lanes.
      if (next !== null) {
        // If completing this work spawned new work, do that next. We'll come
        // back here again.
        // Since we're restarting, remove anything that is not a host effect
        // from the effect tag.
        next.flags &= HostEffectMask;
        workInProgress = next;
        return;
      }
      if (returnFiber !== null) {
        // Mark the parent fiber as incomplete and clear its subtree flags.
        returnFiber.flags |= Incomplete;
        returnFiber.subtreeFlags = NoFlags;
        returnFiber.deletions = null;
      } else {
        // We've unwound all the way to the root.
        workInProgressRootExitStatus = RootDidNotComplete;
        workInProgress = null;
        return;
      }
    }
    const siblingFiber = completedWork.sibling;
    if (siblingFiber !== null) {
      // If there is more work to do in this returnFiber, do that next.
      workInProgress = siblingFiber;
      return;
    }
    // Otherwise, return to the parent
    completedWork = returnFiber;
    // Update the next thing we're working on in case something throws.
    workInProgress = completedWork;
  } while (completedWork !== null);
  // We've reached the root.
  if (workInProgressRootExitStatus === RootInProgress) {
    workInProgressRootExitStatus = RootCompleted;
  }
}

순회 알고리즘에 대한 포스팅에서 설명했듯이, completeUnitWork는 파이버 노드를 조정(reconcile)하는 마지막 단계입니다.

Incomplete 파이버 노드의 경우

const next = unwindWork(current, completedWork, subtreeRenderLanes);
// Because this fiber did not complete, don't reset its lanes.
if (next !== null) {
  // If completing this work spawned new work, do that next. We'll come
  // back here again.
  // Since we're restarting, remove anything that is not a host effect
  // from the effect tag.
  next.flags &= HostEffectMask;
  workInProgress = next;
  return;
}

unwindWork에서 반환된 경우 작업을 계속할 수 있는 기회를 제공한다는 것을 알 수 있습니다.

또한 재귀적으로 조상 노드를 Incomplete로 표시합니다.

이름과 같이 unwindWork는 컨텍스트 등을 정리합니다. 소스

case SuspenseComponent: {
  popSuspenseContext(workInProgress);
  const flags = workInProgress.flags;
  if (flags & ShouldCapture) {
    workInProgress.flags = (flags & ~ShouldCapture) | DidCapture;
    // Captured a suspense effect. Re-render the boundary.
    if (
      enableProfilerTimer &&
      (workInProgress.mode & ProfileMode) !== NoMode
    ) {
      transferActualDuration(workInProgress);
    }
    return workInProgress;
  }
  return null;
}

Suspense로 언와인드 됐을 때, 우리는 다음과 같은 것을 볼 수 있습니다.

  1. 서스펜스 컨텍스트가 튀어 나옵니다(pop), 다음 에피소드에서 다룰 예정입니다.

  2. ShouldCapture를 찾으면 DidCapture를 수행하도록 설정하고 스스로 반환합니다.

맞아요, ShouldCapture는 완료 단계에서 DidCapture로 변환됩니다.

요약

긴 여정을 요약하면, 다음과 같습니다.

  1. Suspense는 폴백 또는 contents(primary children)를 렌더링할지 여부를 결정하기 위해 DidCapture 플래그를 사용합니다.

  2. Suspense는 contents를 Offscreen 컴포넌트로 래핑하여 폴백이 렌더링되더라도 파이버 트리에서 contents가 제거되지 않고 내부 state를 유지하도록 합니다.

  3. 조정하는 동안, Suspense는 DidCapture 플래그를 기반으로 Offscreen 건너뛰기 여부를 결정하며, 이는 "일부 파이버를 숨기는" effect를 생성합니다.

  4. promise가 던져질 때

    • 가장 가까운 Suspense Boundary가 발견되고 ShouldCapture로 플래그가 설정되면, promise는 ping & retry 리스너로 연결됩니다.

    • 에러 이후, 작업을 완료하기 시작하면 에러가 발생한 컴포넌트에서 Suspense까지 모든 파이버가 Incomplete로 완료됩니다.

    • 가장 가까운 Suspense를 완료하려고 할 때, ShouldCaptureDidCapture로 표시되고 Suspense 자체를 반환합니다.

    • 작업루프(workloop)는 Suspense를 계속 조정하고, 이번에는 폴백 브랜치를 렌더링합니다.

  5. promise가 해결(resolved)되면

    • ping & retry 리스너가 리-렌더링이 발생하게 합니다. (자세한 내용은 다음 에피소드에서 설명합니다.)

(원글 작성일: 2022-04-02)