Suspense Deep Dive (Code Implementation)

Yeoul Kim
25 min readMar 26, 2023

--

Overview

이전 포스팅 Conceptual Model of React Suspense과, Algebraic Effects of React Suspense에서 React가 Suspense를 어떤 관점으로 바라보고 있는지, 그리고 어떤 개념적 모델을 바탕으로 구성되어 있는지를 살펴보았습니다. 이번 포스팅에서는 이러한 내용들을 바탕으로 Suspense가 실제로 React 소스 코드 레벨에서 어떻게 구현되어 있고, 구체적으로 어떤 과정을 거쳐 동작하게 되는지를 간략하게 살펴보도록 하겠습니다. (소스 코드는 글을 작성하는 시점의 최신 코드를 기반으로 하지만, React 프로젝트는 코드가 굉장히 자주 바뀌는 편이기 때문에 변경사항이 생길 수 있습니다.)

Recap Example Code

이전 포스팅에서 언급한 React 18 Suspense의 공식 예제를 다시 보도록 하겠습니다. user와 post data를 fetching하는 동안 보여주어야 할 Loading Component를 각 컴포넌트에서 처리하는 것이 아니라 Suspense에서 선언적으로 처리하도록 하는 예제 입니다.

// index.js
const resource = fetchProfileData();

function ProfilePage() {
return (
<Suspense fallback={<h1>Loading profile...</h1>}>
<ProfileDetails />
<Suspense fallback={<h1>Loading posts...</h1>}>
<ProfileTimeline />
</Suspense>
</Suspense>
);
}
function ProfileDetails() {
// Try to read user info, although it might not have loaded yet
const user = resource.user.read();
return <h1>{user.name}</h1>;
}
function ProfileTimeline() {
// Try to read posts, although they might not have loaded yet
const posts = resource.posts.read();
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.text}</li>
))}
</ul>
);
}

해당 컴포넌트에서 리소스를 불러오기 위해 사용하는 fetchProfileData라는 함수는 데이터 로딩시에 보여주어야 할 UI 로직을 Suspense로 넘기기 위해 다음과 같이 algebraic effect이 적용되어 있습니다. 즉, 데이터가 로딩 중일때는 Suspense로 promise를 throw하도록 구현되어 있다는 의미입니다.

export function fetchProfileData() {
let userPromise = fetchUser();
let postsPromise = fetchPosts();
return {
user: wrapPromise(userPromise),
posts: wrapPromise(postsPromise)
};
}

// Suspense integrations like Relay implement
// a contract like this to integrate with React.
// Real implementations can be significantly more complex.
// Don't copy-paste this into your project!
function wrapPromise(promise) {
let status = "pending";
let result;
let suspender = promise.then(
(r) => {
status = "success";
result = r;
},
(e) => {
status = "error";
result = e;
}
);
return {
read() {
if (status === "pending") {
throw suspender;
} else if (status === "error") {
throw result;
} else if (status === "success") {
return result;
}
}
};
}

Brief Pipeline

Suspense의 로직을 크게 두 가지 부분으로 나누어 설명하려고 합니다. 첫 번째는 Scheduler에 의해 스케줄된 Task를 workLoop에서 처리할 때, Suspense와 Fallback Component, Child Component를 렌더링하는 부분이고, 두 번째는 Data Fetching 시에 throw한 Promise를 처리하는 부분입니다.

Rendering

beginwork

React Reconciler에서는 스케줄러에 의해 스케줄된 Task를 workLoop를 돌면서 렌더링합니다. 이때 매 루프마다 작업되는 단위가 performUnitOfWork로 감싸진 beginWork 인데, beginWork로 넘겨진 workInProgress의 tag가 “SuspenseComponent”인 경우, Suspense Component를 처리하기 위한 updateSuspenseComponent 함수를 호출합니다.

/** @noinline */
function workLoopConcurrent() {
// Perform work until Scheduler asks us to yield
while (workInProgress !== null && !shouldYield()) {
// $FlowFixMe[incompatible-call] found when upgrading Flow
performUnitOfWork(workInProgress);
}
}
function performUnitOfWork(unitOfWork: Fiber): void {
...
let next;
if (enableProfilerTimer && (unitOfWork.mode & ProfileMode) !== NoMode) {
startProfilerTimer(unitOfWork);
next = beginWork(current, unitOfWork, renderLanes);
stopProfilerTimerIfRunningAndRecordDelta(unitOfWork, true);
} else {
next = beginWork(current, unitOfWork, renderLanes);
}
...
}
case SuspenseComponent:
return updateSuspenseComponent(current, workInProgress, renderLanes);

updateSuspenseComponent

updateSuspenseComponent는 크게 두 가지 부분으로 구성됩니다. 조건에 맞추어 “showFallback” 이라는 매우 직관적인 이름을 가진 플래그를 처리하는 부분과, 이 플래그를 기준으로 Fallback UI를 보여줄 것인지, Child Component UI를 보여줄 것인지를 결정하는 부분입니다.

먼저 플래그를 처리하는 부분을 살펴보겠습니다. showFallback 이라는 플래그는 didSuspend와 shouldRemainOnFallback 이라는 함수의 리턴값을 기준으로 해당 Suspense 컴포넌트의 Fallback UI 렌더링 여부를 결정합니다. 간단하게 설명하면, didSuspend라는 플래그는 현재 Suspense 컴포넌트가 비동기 데이터를 가져오는 동안에 발생한 Suspend를 캡쳐했는지, (즉 로딩중인지) 를 나타내는 bit flag 입니다. (해당 flag를 잡기 위해 bitwise 연산을 수행하는 것을 볼 수 있습니다.) shouldRemainOnFallback은 Suspense 컴포넌트가 여전히 fallback 상태로 남아 있어야 하는지를 결정하며, 비동기 데이터 요청이 완료되었더라도 다른 우선순위가 더 높은 업데이트로 인해 아직 fallback 상태를 유지해야 하는 경우 true로 설정됩니다.(Concurrent React..!)

function updateSuspenseComponent(current, workInProgress, renderLanes) {
const nextProps = workInProgress.pendingProps;

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

다음으로는 플래그를 기준으로 Fallback UI를 보여줄 것인지, Child Component UI를 보여줄 것인지를 결정하는 부분을 살펴보겠습니다. (current === null 인 경우는 mount phase, current !== null 인 경우는 update phase라는 의미입니다.) 위의 로직에서 결정한 showFallback이 true인 경우, mountSuspenseFallbackChildren을 호출하고, false인 경우 mountSuspensePrimaryChildren을 호출하는 것을 확인할 수 있습니다.

if (current === null) {
// Initial mount
const nextPrimaryChildren = nextProps.children;
const nextFallbackChildren = nextProps.fallback;

if (showFallback) {
pushFallbackTreeSuspenseHandler(workInProgress);
const fallbackFragment = mountSuspenseFallbackChildren(
workInProgress,
nextPrimaryChildren,
nextFallbackChildren,
renderLanes,
);

const primaryChildFragment: Fiber = (workInProgress.child: any);
primaryChildFragment.memoizedState = mountSuspenseOffscreenState(
renderLanes,
);

workInProgress.memoizedState = SUSPENDED_MARKER;
return fallbackFragment;
} else {
pushPrimaryTreeSuspenseHandler(workInProgress);
return mountSuspensePrimaryChildren(
workInProgress,
nextPrimaryChildren,
renderLanes,
);
}
}

mountSuspenseFallbackChildren

mountSuspenseFallbackChildren은 실제로 Fallback UI와 Child Component를 모두 렌더링합니다. 이때 Fallback UI는 createFiberFromFragment를 사용해서 렌더링하고, Child Component는 mountWorkInProgressOffscreenFiber를 사용해서 렌더링하는데, 이때 들어가는 primaryChildProps의 mode 속성이 ‘hidden’으로 설정됩니다. 이는 Suspense 렌더링에서 핵심적인 부분을 담당하는데, DOM에는 반영되지 않지만(실제로 해당 노드는 display:none!important가 걸린 div로 업데이트되어 DOM에 나타나지 않습니다.), 백그라운드에서 해당 컴포넌트가 렌더링되어 실제 Data fetching과 reconcilation 대상이 되는 것입니다.

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;
}
export function hideInstance(instance: Instance): void {
// TODO: Does this work for all element types? What about MathML? Should we
// pass host context to this method?
instance = ((instance: any): HTMLElement);
const style = instance.style;
// $FlowFixMe[method-unbinding]
if (typeof style.setProperty === 'function') {
style.setProperty('display', 'none', 'important');
} else {
style.display = 'none';
}
}

mountSuspensePrimaryChildren

컴포넌트 렌더링을 위한 모든 데이터가 준비되고 충분한 렌더링 우선순위를 확보하게 되면, showFallback 플래그는 false로 바뀌게 되고, mountSuspensePrimaryChildren 에서는 mode: ‘visible’를 속성으로 해서 다시 mountWorkInProgressOffscreenFiber를 통해 ChildComponent를 렌더링합니다.

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;
}
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 = {
visibility: OffscreenVisible,
pendingMarkers: null,
retryCache: null,
transitions: null,
};
fiber.stateNode = primaryChildInstance;
return fiber;
}

Handling promise

다음은 ChildComponent가 데이터를 가져오는 동안 Suspense가 이를 Capture해서 showFallback을 true로 바꾸기 위해 어떤 일들이 일어나는가에 대한 부분입니다. Suspense의 개념적 모델을 다룬 이전 포스팅에서도 간단히 살펴보았듯, ChildComponent에서 데이터를 가져오는 promise를 throw하면, Suspense에서 이를 잡아 resolve한 후, 다시 제어권을 ChildComponent로 넘기는 방식으로 진행되므로 이 throw된 Promise를 처리하는 부분의 로직을 살펴보아야 할 것입니다. (promise를 던지는 data fetching 로직은 글 서두에 언급했던 예제를 확인해주세요.)

우선 다시 renderRootConcurrent로 돌아가겠습니다. 각 Fiber를 돌면서 렌더링을 처리하는 workLoopConcurrent라는 로직의 호출부를 try / catch로 한번 감싸고, do / while loop으로 한번 더 감싼 것을 확인할 수 있습니다. try / catch는 workLoopConcurrent에서 각 Fiber를 렌더링하기 위해 beginWork를 처리하는데, 이때 Error나 Promise를 throw하는 경우, 이를 catch해서 처리하기 위한 로직(handleError)이며, do / while loop은 promise가 throw된 경우, 이를 기다리기 위한 로직입니다.

outer: do {
try {
if (
workInProgressSuspendedReason !== NotSuspended &&
workInProgress !== null
) {
// The work loop is suspended. We need to either unwind the stack or
// replay the suspended component.
const unitOfWork = workInProgress;
const thrownValue = workInProgressThrownValue;
switch (workInProgressSuspendedReason) {
case SuspendedOnError: {
// Unwind then continue with the normal work loop.
workInProgressSuspendedReason = NotSuspended;
workInProgressThrownValue = null;
unwindSuspendedUnitOfWork(unitOfWork, thrownValue);
break;
}
case SuspendedOnData: {
const thenable: Thenable<mixed> = (thrownValue: any);
if (isThenableResolved(thenable)) {
// The data resolved. Try rendering the component again.
workInProgressSuspendedReason = NotSuspended;
workInProgressThrownValue = null;
replaySuspendedUnitOfWork(unitOfWork);
break;
}
// The work loop is suspended on data. We should wait for it to
// resolve before continuing to render.
const onResolution = () => {
ensureRootIsScheduled(root, now());
};
thenable.then(onResolution, onResolution);
break outer;
}
...
}
}
workLoopConcurrent();
break;
} catch (thrownValue) {
handleThrow(root, thrownValue);
}
} while (true);

handleThrow

handleThrow에서는 workLoopConcurrent에서 throw한 것이 SuspenseException 이라는 특별한 값인 경우, thrownValue를 thenable로 만들고 workInProgressSuspendedReason을 SuspendedOnData라는 값으로 설정함으로써, 위의 do / while loop에서 이를 처리할 수 있도록 해줍니다.

function handleThrow(root, thrownValue): void {
// A component threw an exception. Usually this is because it suspended, but
// it also includes regular program errors.
//
// We're either going to unwind the stack to show a Suspense or error
// boundary, or we're going to replay the component again. Like after a
// promise resolves.
//
// Until we decide whether we're going to unwind or replay, we should preserve
// the current state of the work loop without resetting anything.
//
// If we do decide to unwind the stack, module-level variables will be reset
// in resetSuspendedWorkLoopOnUnwind.
// These should be reset immediately because they're only supposed to be set
// when React is executing user code.
resetHooksAfterThrow();
resetCurrentDebugFiberInDEV();
ReactCurrentOwner.current = null;
if (thrownValue === SuspenseException) {
// This is a special type of exception used for Suspense. For historical
// reasons, the rest of the Suspense implementation expects the thrown value
// to be a thenable, because before `use` existed that was the (unstable)
// API for suspending. This implementation detail can change later, once we
// deprecate the old API in favor of `use`.
thrownValue = getSuspendedThenable();
workInProgressSuspendedReason = shouldAttemptToSuspendUntilDataResolves()
? SuspendedOnData
: SuspendedOnImmediate;
}
...
workInProgressThrownValue = thrownValue;
...
}

do / while loop

do / while loop에서는 이 값을 thenable로 받아서 .then으로 이 값을 resolve하고, resolve가 다 된 이후에는 resolve가 된 시점을 기준으로 다시 스케줄러에 task를 등록함으로써 Suspense가 Child Component를 렌더링할 수 있도록 스케줄링 합니다. 이렇게 함으로써 Suspense는 promise가 throw된 경우 이를 catch해서 do / while loop을 돌면서 이 promise를 기다리고, promise가 resolve 될때까지 해당 renderRootConcurrent를 기다리게 했다가 resolve가 되면 다시 새로운 task를 스케줄러에 등록하도록 하는 식으로 promise를 처리합니다.

case SuspendedOnData: {
const thenable: Thenable<mixed> = (thrownValue: any);
if (isThenableResolved(thenable)) {
// The data resolved. Try rendering the component again.
workInProgressSuspendedReason = NotSuspended;
workInProgressThrownValue = null;
replaySuspendedUnitOfWork(unitOfWork);
break;
}
// The work loop is suspended on data. We should wait for it to
// resolve before continuing to render.
const onResolution = () => {
ensureRootIsScheduled(root, now());
};
thenable.then(onResolution, onResolution);
break outer;
}

Conclusion

지금까지 여러 차례에 걸쳐 Suspense의 개념적 모델과 대수적 효과, 그리고 React Team 이 바라보는 Suspense의 모습과(SSR Architecture) Concurrent React에서의 Implementation에 대해 살펴보았습니다. Concurrent Mode에서의 Suspense는 단순히 로딩 UI를 보여주기 위한 것이 아닌 React의 철학(선언형 UI 라이브러리로서의 React)이 녹아들어있는 핵심적인 로직을 담당합니다.

--

--