💡 Suspense의 기본 구조
- DidCapture 플래그: Suspense가 현재 일시 중단(Suspended) 상태인지 아닌지를 나타낸다.
- Offscreen 컴포넌트: Suspense의 자식 요소들을 감싸는 특별한 컴포넌트
Suspense가 일시 중단되면, Offscreen 내부의 내용 대신 Fallback이 렌더링된다.
여기서 중요한 점은 Offscreen 컴포넌트가 Fiber 트리에서 제거되지 않고 유지된다는 점이다.
➔ 그 이유는 상태를 보존하기 위함이다.
Promise(thenable)가 발생하면 다음과 같은 과정을 거친다.
React는 Fiber 트리에서 가장 가까운 상위 Suspense 컴포넌트를 찾는다.
해당 Suspense 컴포넌트에 ShouldCapture
표시를 한다.
언와이딩 과정(완료 과정)동안 이미 설정된 ShouldCapture
플래그를 확인하고 처리한다.
이 시점에서 React는 부모 노드로 올라가지 않고, Suspense 컴포넌트에서 다시 reconciliation(재조정) 과정을 시작한다.
ShouldCapture
에서 DidCapture
플래그로 변경되고, Suspense는 Fallback을 렌더링한다.
원래의 자식 컴포넌트들은 Fiber 트리에서 제거되지 않고 Offscreen 컴포넌트 내부에 유지되어 상태를 보존한다.
React는 throw된 Promise가 resolve되면, React는 해당 Suspense 경계에서 렌더링을 다시 시도하여 Offscreen에 유지되었던 원래의 자식 컴포넌트들을 다시 렌더링한다.
const fetcher = createFetcher("완료", 8000)
function AsyncComponent() {
return fetcher.fetch()
}
function App() {
return <>
<span>결과 값: </span>
<Suspense fallback={<span>loading...</span>}>
<AsyncComponent/>
</Suspense>
</>
}
Suspense는 React의 특별한 컴포넌트로 일반적인 HTML 요소와 달리 직접적으로 대응되는 HTML 태그가 없다.
그래서 React는 Suspense를 HTML로 직렬화할 때 특별한 방법을 사용한다.
/* React의 SSR에서 Suspense 경계는 HTML 주석으로 인코딩된다. */
// 완료된 Suspense 경계의 시작을 나타낸다.
// 자식 컴포넌트가 모두 로드되어 정상적으로 렌더링된 상태이다.
const startCompletedSuspenseBoundary = stringToPrecomputedChunk('<!--$-->');
// 대기 중인 Suspense 경계의 시작 부분이다.
// <template> 태그의 사작과 함께 사용되며 아직 렌더링되지 않은 컨텐츠를 위한 것이다.
// Progressive(점진적) Hydration과 관련이 있다.
const startPendingSuspenseBoundary1 = stringToPrecomputedChunk(
'<!--$?--><template id="',
);
// 대기 중인 Suspense 경계의 <template> 태그를 닫는다.
const startPendingSuspenseBoundary2 = stringToPrecomputedChunk('"></template>');
// 클라이언트에서 렌더링될 Suspense 경계의 시작을 나타낸다.
// 이는 서버에서 fallback이 렌더링된 경우를 의미한다.
const startClientRenderedSuspenseBoundary =
stringToPrecomputedChunk('<!--$!-->');
// 모든 유형은 Suspense 경계의 끝을 나타낸다.
const endSuspenseBoundary = stringToPrecomputedChunk('<!--/$-->');
Suspense 컴포넌트는 서버 사이드 렌더링 시 3가지 상태로 렌더링될 수 있다.
완료된 상태(<!--$-->
): 모든 자식 컴포넌트가 성공적으로 렌더링된 경우
대기 중인 상태(<!--$?--><templaate id="...">
): 자식 컴포넌트의 데이터가 아직 준비되지 않은 경우
클라이언트 렌더링 상태(<!--$!-->
): 서버에서 fallback을 렌더링한 경우
updateSuspenseComponent
함수에서 Hydartion에 대한 분기를 찾아볼 수 있다.
function updateSuspenseComponent(
current: null | Fiber,
workInProgress: Fiber,
renderLanes: Lanes,
) {
// another conde...
if (current === null) { // 초기 마운트
if (getIsHydrating()) { // Hydration중인지 확인
// fallback을 보여줄지 여부에 따라 적절한 Suspense 핸들러 설정
if (showFallback) {
pushPrimaryTreeSuspenseHandler(workInProgress);
} else {
pushFallbackTreeSuspenseHandler(workInProgress);
}
// 서버에서 렌더링된 Suspense 인스턴스를 찾아 재사용하려고 시도
tryToClaimNextHydratableSuspenseInstance(workInProgress);
const suspenseState: null | SuspenseState = workInProgress.memoizedState;
if (suspenseState !== null) { // Hydration 상태를 확인하고
const dehydrated = suspenseState.dehydrated;
if (dehydrated !== null) { // 성공적으로 Hydration이 된 경우 컴포넌트를 마운트
return mountDehydratedSuspenseComponent(
workInProgress,
dehydrated,
renderLanes,
);
}
}
// Hydration 실패 처리
popSuspenseHandler(workInProgress);
}
}
}
이 함수는 Suspense 컴포넌트의 초기 마운트 과정을 처리한다.
function tryToClaimNextHydratableSuspenseInstance(fiber: Fiber): void {
if (!isHydrating) { // Hydration 중이 아니라면 return
return;
}
const nextInstance = nextHydratableInstance;
// 핵심 로직은 tryHydrateSuspense 함수이다.
if (!nextInstance || !tryHydrateSuspense(fiber, nextInstance)) {
// Hydration 불일치 시 에러 발생
warnNonHydratedInstance(fiber, nextInstance);
throwOnHydrationMismatch(fiber);
}
}
// Suspense 컴포넌트를 Hydration(서버에서 렌더링된 HTML을 React 컴포넌트로 활성화)한다.
function tryHydrateSuspense(fiber: Fiber, nextInstance: any) {
// DOM 인스턴스가 Suspense 컴포넌트로 Hydration이 될 수 있는지 확인
// canHydrateSuspenseInstance 함수는 DOM 인스턴스가 주석 노드인지를 확인한다.
const suspenseInstance = canHydrateSuspenseInstance(
nextInstance,
rootOrSingletonContext,
);
// Hydartion 가능한 경우
if (suspenseInstance !== null) {
// Suspense 상태 생성
const suspenseState: SuspenseState = {
dehydrated: suspenseInstance,
treeContext: getSuspendedTreeContext(),
retryLane: OffscreenLane, // 재시도할 때 사용할 레인
};
fiber.memoizedState = suspenseState; // Fiber 노드의 memoizedState로 설정
// dehydrated 상태의 Fragment를 나타내는 Fiber 노드를 생성
const dehydratedFragment =
createFiberFromDehydratedFragment(suspenseInstance);
dehydratedFragment.return = fiber;
// Suspense Fiber의 자식으로 설정
// 나중에 호스트 형제 노드를 찾거나 노드를 삭제할 때 코드를 단순화
fiber.child = dehydratedFragment;
hydrationParentFiber = fiber; // 현재 Suspense Fiber를 Hydration 부모로 설정
// 다음 Hydration 대상을 null로 설정
// Suspense 내부의 자식들을 첫 번째 패스에서 처리하지 않고 나중에 다시 진입
nextHydratableInstance = null;
return true; // Hydration 성공
}
return false; // Hydration 실패
}
<!--$-->
, <!--$!-->
)memoizedState
를 설정한다.suspenseState
객체는 Suspense의 Hydration 상태를 추적하는 데 중요한 역할을 한다.dehydratedFragment
는 Suspense의 자식 컴포넌트들을 나중에 Hydrate하기 위한 Placeholder 역할을 하고 Progressive(점진적) Hydration을 가능하게 한다.function mountDehydratedSuspenseComponent(
workInProgress: Fiber, // 현재 작업중인 Fiber 노드
suspenseInstance: SuspenseInstance, // 서버에서 렌더링된 Suspense 인스턴스
renderLanes: Lanes // 렌더링 우선순위
): null | Fiber {
// Suspense 인스턴스가 Fallback 상태인지 확인
if (isSuspenseInstanceFallback(suspenseInstance)) {
// 클라이언트 전용 경계
// 서버에서 컨텐츠를 받지 않아서, 더 높은 우선순위로 스케줄링
// DefaultHydrationLane를 사용하여 우선순위 설정
// 별도의 커밋에서 렌더링될 작업을 남겨둔다.
workInProgress.lanes = laneToLanes(DefaultHydrationLane);
} else { // Fallback 상태가 아닌 경우
// 서버에서 이미 올바른 콘텐츠를 받았기 때문에 급하게 처리할 필요가 없다.
// OffscreenLane를 사용하여 낮은 우선순위로 설정
workInProgress.lanes = laneToLanes(OffscreenLane);
}
return null;
}
const SUSPENSE_START_DATA = "$"; // 시작
const SUSPENSE_END_DATA = "/$"; // 끝
const SUSPENSE_PENDING_START_DATA = "$?"; // 대기 중 시작
const SUSPENSE_FALLBACK_START_DATA = "$!"; // Fallback 시작
export function isSuspenseInstanceFallback(
instance: SuspenseInstance
): boolean {
// Suspense 인스턴스가 Fallback 상태인지 확인
return instance.data === SUSPENSE_FALLBACK_START_DATA;
}
DefaultHydrationLane
은 높은 우선순위로 빠른 Hydration이 필요한 경우에 사용한다.OffscreenLane
는 낮은 우선순위로 당장 화면에 표시되지 않는 컨텐츠에 사용된다.Suspense 컴포넌트를 만나면 React는 해당 Suspense에 대한 Fiber 노드를 생성하고 Suspense의 자식 컴포넌트나 Fallback에 대한 Fiber 노드를 생성하지 않는 대신 dehydrateFragment
라는 특별한 Fiber 노드를 생성한다.
💡
dehydratedFragment
란?
Suspense의 실제 자식 컴포넌트나 Fallback 콘텐츠를 나중에 Hydrate하기 위한 Placeholder 역할을 한다.
이는 Suspense 컴포넌트의 상태를 초기화하는 데 도움을 준다.
🎯 첫 번째 패스에서 React는
dehydrateFragment
와 Suspense 주석 노드만 보존하고 commit 단계로 넘어간다. 이 과정에서 Hydration 상태를 설정하고 다음 렌더링 패스를 위한 준비를 한다.
이 단계에서 React는 서버에서 렌더링된 HTML 구조를 유지하면서, Suspense 컴포넌트의 상태를 초기화한다.
이는 Progressive(점진적) Hydration을 가능하게 하며, 전체 애플리케이션의 Hydration을 지연시키지 않고 부분적으로 상호작용 가능한 UI를 빠르게 제공할 수 있다.
다시 updateSuspenseComponent
함수로 들어가고 이번에는 current
Fiber 트리가 존재하기 때문에 update 분기로 진입한다.
function updateSuspenseComponent(
current: null | Fiber,
workInProgress: Fiber,
renderLanes: Lanes,
) {
// another conde...
if (current === null) {
// 초기 마운트
...
} else {
// 업데이트
const prevState: null | SuspenseState = current.memoizedState;
if (prevState !== null) {
// 이전에 매칭된 dehydrated supense 상태가 있다면
const dehydrated = prevState.dehydrated;
if (dehydrated !== null) {
return updateDehydratedSuspenseComponent(
current,
workInProgress,
didSuspend,
nextProps,
dehydrated,
prevState,
renderLanes,
);
}
}
// another conde...
}
첫 번째 렌더링 패스에서 memoizedState
를 설정하였기 때문에 존재하므로 updateDehydratedSuspenseComponent
함수를 호출한다.
function updateDehydratedSuspenseComponent(
current,
workInProgress,
didSuspend,
didPrimaryChildrenDefer,
nextProps,
suspenseInstance,
suspenseState,
renderLanes,
) {
/*
didSuspend의 목적
true: 현재 렌더링 과정에서 컴포넌트가 중단되었음을 나타낸다.(후속 렌더링)
false: 컴포넌트가 중단되지 않은 경우(초기 렌더링)
*/
if (!didSuspend) { // 초기 렌더링
if (isSuspenseInstanceFallback(suspenseInstance)) {
// Suspense 인스턴스 상태가 fallback이라면 Hydrate를 시도하지 않고 클라이언트 렌더링으로 전환
return retrySuspenseComponentWithoutHydrating(
current,
workInProgress,
renderLanes,
);
}
// 컴포넌트가 업데이트를 받았거나 컨텍스트가 변경된 경우
if (didReceiveUpdate || hasContextChanged) {
// Hydarte 불가능 상황 처리
const root = getWorkInProgressRoot();
if (root !== null) {
const attemptHydrationAtLane = getBumpedLaneForHydration(
root,
renderLanes,
);
if (
attemptHydrationAtLane !== NoLane &&
attemptHydrationAtLane !== suspenseState.retryLane
) {
// 더 높은 우선순위로 Hydrate 재시도
suspenseState.retryLane = attemptHydrationAtLane;
// 업데이트 스케줄링
throw SelectiveHydrationException;
}
}
// Hydrate 실패 시 클라이언트 렌더링으로 전환
return retrySuspenseComponentWithoutHydrating(
current,
workInProgress,
renderLanes,
);
} else if (isSuspenseInstancePending(suspenseInstance)) {
// 대기 중인 Suspense 인스턴스 처리
workInProgress.flags |= DidCapture;
workInProgress.child = current.child;
// 재시도 함수 등록
return null;
} else {
// Hydrate 성공 시 주 자식 컴포넌트 마운트
reenterHydrationStateFromDehydratedSuspenseInstance(
workInProgress,
suspenseInstance,
suspenseState.treeContext,
);
const primaryChildFragment = mountSuspensePrimaryChildren(
workInProgress,
nextProps.children,
renderLanes,
);
// Hydrating 플래그를 설정하여 Hydrate 과정임을 표시
primaryChildFragment.flags |= Hydrating;
return primaryChildFragment;
}
} else { // 컴포넌트가 이미 한 번 렌더링되었고, 중단되었거나 오류가 발생한 경우
if (workInProgress.flags & ForceClientRender) {
// Hydrate 중 오류 발생 시 클라이언트 렌더링으로 전환
return retrySuspenseComponentWithoutHydrating(
current,
workInProgress,
renderLanes,
);
} else if ((workInProgress.memoizedState: null | SuspenseState) !== null) {
// 컴포넌트가 중단된 상태라면 현재 자식을 유지하고 DidCapture 플래그 설정
workInProgress.child = current.child;
workInProgress.flags |= DidCapture;
return null;
} else {
// Hydrate가 실패하고 중단 상태가 아닌 경우 fallback 컨텐츠 렌더링
const fallbackChildFragment =
mountSuspenseFallbackAfterRetryWithoutHydrating(
current,
workInProgress,
nextProps.children,
nextProps.fallback,
renderLanes,
);
// 상태 및 레인 설정
return fallbackChildFragment;
}
}
}
Hydration 초기 단계
Hydration 후속 단계(주로 Hydration 과정에서 문제가 발생했거나 컴포넌트가 중단된 경우를 처리)
function retrySuspenseComponentWithoutHydrating(
current: Fiber,
workInProgress: Fiber,
renderLanes: Lanes
) {
// 기존 자식의 Fiber들을 삭제 목록에 추가하여 제거
reconcileChildFibers(workInProgress, current.child, null, renderLanes);
const nextProps = workInProgress.pendingProps;
// Suspense의 주요 자식들(Fallback이 아닌 실제 내용)을 새로 마운트
const primaryChildren = nextProps.children;
const primaryChildFragment = mountSuspensePrimaryChildren(
workInProgress,
primaryChildren,
renderLanes
);
// 새로 생성된 자식에 플래그 설정, 이는 React에게 Fragment를 DOM에 새로 삽입해야 함을 알린다.
primaryChildFragment.flags |= Placement;
workInProgress.memoizedState = null; // Suspense 컴포넌트의 이전 상태를 제거한다.
return primaryChildFragment; // 새로 생성된 자식 Fragment를 반환한다.
}
DehydratedFragment
자식을 제거한다.mountSuspensePrimaryChildren
함수로 새로운 자식들을 마운트한다.Placement
플래그를 통해 새로 생성된 자식 Fragment를 DOM에 삽입해야 한다는 것을 알린다.workInProgress.memoizedState
를 null로 설정한다.이 함수는 Suspense 컴포넌트의 Hydration이 실패했을 때 호출되는 함수이다. 즉 서버에서 렌더링된 내용을 삭제하고 클라이언트에서 새로 렌더링한다.
<!--$-->
)와 함께 실제 콘텐츠를 포함한다.<!--$!-->
)를 사용하여 이를 표시한다.tryToClaimNextHydratableSuspenseInstance
함수를 통해 서버에서 렌더링된 HTML의 Suspense 주석 노드를 찾는다.workInProgress.memoizedState
에 SuspenseState
객체를 설정한다.mountDehydratedSuspenseComponent
함수를 호출하여 Suspense 내부의 요소를 탐색하지 않고 중단하고 두 번째 패스를 예약한다.첫 번째 패스에서는 Suspense 컴포넌트를 만나면 React는 해당 Suspense에 대한 Fiber 노드를 생성하고 Suspense의 자식 컴포넌트나 Fallback에 대한 Fiber 노드를 생성하지 않는 대신 dehydrateFragment
라는 특별한 Fiber 노드를 생성한다.
React는 dehydrateFragment
와 Suspense의 주석 노드만 보존하고 commit 단계로 넘어간다. 이 과정에서 Hydration 상태를 설정하고 다음 렌더링 패스를 위한 준비(우선순위 설정 등)를 한다.
updateDehydratedSuspenseComponent
함수에서 Suspense 컴포넌트의 현재 상태를 확인하고, 적절한 렌더링 경로를 선택한다.올바른 콘텐츠를 받은 경우
Fallback 상태일 때
retrySuspenseComponentWithoutHydrating
함수를 호출하여 클라이언트 렌더링으로 전환한다.두 번째 패스에서는 클라이언트의 현재 상태와 서버에서 렌더링된 내용과 비교한 후, 클라이언트와 서버 상태 모두 실제 콘텐츠를 가지고 있는 경우 Hydartion을 수행하고, Fallback 상태이거나 불일치하는 경우 클라이언트 렌더링으로 전환하여 일관된 UI를 제공한다.
이 과정에서 React는 사용자 상호작용이나 네트워크 상태 변화에 따라 우선순위를 동적으로 조절할 수 있다.
💡 Suspense의 Hydration 과정은 React의 점진적 Hydration 전략의 일부로, 전체 애플리케이션의 Hydration을 지연시키지 않고 부분적으로 상호작용 가능한 UI를 빠르게 제공할 수 있게 한다.
How hydration works with Suspense internally in React?
Upgrading to React 18 on the server #22
New Suspense SSR Architecture in React 18 #37
좋은 내용인데 전부다 폰트를 굵게 하셔서 그런지 읽기가 쉽지가 않네요;;