Read OSS

App Router レンダリングエンジンの内側:RSC、ストリーミング、PPR

上級

前提知識

  • 第1・2回:アーキテクチャ概要およびサーバー起動・リクエストライフサイクル
  • React Server Components の基礎知識 — サーバー/クライアント境界、'use client' および 'use server' ディレクティブ
  • Node.js Streams — ReadableStream、TransformStream、パイピング
  • リクエストスコープのコンテキスト伝播に使う Node.js AsyncLocalStorage

App Router レンダリングエンジンの内側:RSC、ストリーミング、PPR

Next.js App Router の中核を担うのが app-render.tsx です。約 7,350 行にのぼるこのファイルは、React Server Components のレンダリングから HTML ストリーミング、Partial Prerendering まで、あらゆる処理を統括しています。クライアントナビゲーション用の Flight ペイロード、初期 HTML 配信用の Fizz ストリーム、静的なシェルに動的な穴を開ける postpone の仕組みなど、フレームワークの最も高度な機能がここに集結しています。

この記事では、ルートモジュールのエントリーポイントからストリーミング出力に至るまでのレンダリングパイプラインを追いながら、その裏側にある設計パターンを明らかにしていきます。

ルートモジュールシステム:ユーザーコードをラップする

第2回で確認したとおり、ルートマッチング後のリクエストはルートモジュールへディスパッチされます。App Router のページにおけるエントリーポイントは AppPageRouteModule です。

classDiagram
    class RouteModule {
        <<abstract>>
        +definition: RouteDefinition
        +userland: UserlandModule
        +setup(): Promise
        +handle(req, res, context)
        #manifests: Manifests
        #incrementalCache: IncrementalCache
    }

    class AppPageRouteModule {
        +render(req, res, context)
        -vendoredReactRSC
        -vendoredReactSSR
        +loaderTree: LoaderTree
    }

    RouteModule <|-- AppPageRouteModule

ルートモジュールは、サーバーの汎用リクエストパイプラインとレンダリングエンジンをつなぐ橋渡し役です。ここで注目すべきは React のロード方法です。module.ts#L40-L53 では、React を 2 つの独立したインスタンスとして読み込みます。サーバーコンポーネントのレンダリング用に vendoredReactRSC、HTML レンダリング用に vendoredReactSSR を使い分けています。いずれも Next.js が管理するベンダーコピーであり、バージョンの一貫性を保ちつつ、ユーザーがインストールした React との競合を防いでいます。

59 行目にある AppPageModule 型を見ると、バンドルされたアプリページモジュールの形が分かります。loaderTree を持っており、これは app/ ディレクトリから探索されたネストされたレイアウト/ページの階層を表すデータ構造です。

AsyncLocalStorage による依存性の注入

レンダリングの詳細に入る前に、Next.js がどのようにして props のバケツリレーなしに深くネストされたコンポーネントツリーへコンテキストを渡しているかを理解しておく必要があります。その答えが AsyncLocalStorage——非同期境界を越えてコンテキストを伝播する Node.js の仕組みです。

中心となるストアは 2 つあります。

WorkStore — ページのレンダリング全体を通じて保持される、レンダー単位のコンテキストです。

export interface WorkStore {
  readonly isStaticGeneration: boolean
  readonly page: string
  readonly route: string
  readonly incrementalCache?: IncrementalCache
  readonly cacheLifeProfiles?: { [profile: string]: CacheLife }
  forceDynamic?: boolean
  forceStatic?: boolean
}

RequestStore — 実際の HTTP データを持つ、リクエスト単位のコンテキストです。

export interface RequestStore extends CommonWorkUnitStore {
  readonly type: 'request'
  readonly url: { readonly pathname: string; readonly search: string }
  readonly headers: ReadonlyHeaders
  cookies: ReadonlyRequestCookies
  readonly mutableCookies: ResponseCookies
}
flowchart TD
    Render["renderToHTMLOrFlight()"] --> WS["WorkStore\n(AsyncLocalStorage)"]
    Render --> RS["RequestStore\n(AsyncLocalStorage)"]

    WS --> HeadersAPI["headers()"]
    WS --> CookiesAPI["cookies()"]
    WS --> CacheDecisions["Cache/Static decisions"]

    RS --> HeadersAPI
    RS --> CookiesAPI
    RS --> DraftMode["draftMode()"]

    subgraph "User Code (Server Components)"
        HeadersAPI
        CookiesAPI
        DraftMode
    end

Server Component の中で headers() を呼ぶと、その実装は workUnitAsyncStorage.getStore() を通じて RequestStore からデータを読み取ります。Next.js の API が明示的な引数なしに動作できるのはこのためです——実行時の非同期コンテキストから必要な値を取り出しているのです。これはランタイムによる依存性注入であり、これらの API がレンダリング中にしか呼べない(ストアが見つからない場合はエラーをスローする)理由でもあります。

補足: これらのファイルに付く .external.ts というサフィックスには意味があります。このファイルがバンドラーの境界をまたいで共有されることを示しており、with { 'turbopack-transition': 'next-shared' } のようなインポート属性を使って、モジュールの解決方法に関わらず同じインスタンスが使われることを保証しています。

renderToHTMLOrFlight:レンダリングのコアエントリーポイント

公開エントリーポイントは、2691 行目にある renderToHTMLOrFlight です。リクエストヘッダーを解析してレンダリングモードを判定し、2205 行目 から始まる約 500 行の renderToHTMLOrFlightImpl に処理を委ねます。

ここでの重要な分岐は「HTML か Flight か」という判断です。

flowchart TD
    Entry["renderToHTMLOrFlight()"] --> Parse["Parse Request Headers"]
    Parse --> RSCCheck{"RSC_HEADER\npresent?"}

    RSCCheck -->|Yes| Prefetch{"Prefetch\nrequest?"}
    RSCCheck -->|No| HTML["Full HTML Render\n(Fizz streaming)"]

    Prefetch -->|Yes| PrefetchFlight["Prefetch Flight Payload\n(static shell only)"]
    Prefetch -->|No| NavigationFlight["Navigation Flight Payload\n(full RSC data)"]

    HTML --> Postponed{"Postponed\nstate?"}
    Postponed -->|Yes| Resume["Resume PPR\n(fill dynamic holes)"]
    Postponed -->|No| FullRender["Full Server Render"]

    FullRender --> StaticCheck{"Static\ngeneration?"}
    StaticCheck -->|Yes| StaticPrerender["Prerender\n(cache result)"]
    StaticCheck -->|No| DynamicRender["Dynamic Render\n(stream to client)"]

ブラウザが初回ページロードを行うとき、RSC_HEADER は存在しません。サーバーは React の Fizz ストリーミングレンダラーを使って完全な HTML を生成します。一方、クライアントサイドルーターがナビゲーションを行う場合(第4回で詳しく見ます)、RSC_HEADER 付きのリクエストが送られ、サーバーは HTML の代わりに Flight ペイロードを返します。Flight ペイロードは、React ツリーを HTML なしで表現するバイナリフォーマットです。

renderToHTMLOrFlightImplcreateWorkStore()createRequestStoreForRender() を通じて WorkStoreRequestStore をセットアップし、React 要素ツリーを構築してレンダリングを実行します。

コンポーネントツリーの構築

create-component-tree.tsx モジュールは、next-app-loaderapp/ ディレクトリの構造から生成したローダーツリーを、React 要素ツリーへと変換します。

graph TD
    LoaderTree["Loader Tree\n(from next-app-loader)"] --> RootLayout["Root Layout\n(app/layout.tsx)"]
    RootLayout --> ErrorBoundary1["ErrorBoundary"]
    ErrorBoundary1 --> Template1["Template"]
    Template1 --> Suspense1["Suspense\n(loading.tsx)"]
    Suspense1 --> NestedLayout["Nested Layout\n(app/dashboard/layout.tsx)"]
    NestedLayout --> ErrorBoundary2["ErrorBoundary"]
    ErrorBoundary2 --> Template2["Template"]
    Template2 --> Suspense2["Suspense"]
    Suspense2 --> Page["Page\n(app/dashboard/page.tsx)"]

ローダーツリーの各セグメントに対して、create-component-tree はエラーバウンダリ(error.tsx から生成)、ローディング状態(loading.tsx から生成)、テンプレート(template.tsx から生成)でコンポーネントをラップします。このラッピングこそが、App Router のきめ細かいエラーハンドリングとローディング UI を実現している仕組みです。各セグメントが独立してローディングスピナーを表示したりエラーをキャッチしたりでき、兄弟セグメントには影響しません。

この関数は 'use client' 境界も処理します。クライアントリファレンスに遭遇した場合、サーバー上ではそのコンポーネントをレンダリングしません。代わりにクライアントリファレンスマーカーを出力し、「このモジュールをロードしてクライアントサイドでレンダリングせよ」という情報を Flight プロトコル経由でブラウザに伝えます。

ストリーミングパイプライン:Fizz と Flight

レンダリング結果は常にストリームであり、完全な文字列になることはありません。Next.js は 2 つの React レンダラーを使い分けています。

  1. Fizz (react-dom/server) — Suspense バウンダリをサポートしながら、React 要素を HTML ストリームにレンダリングする
  2. Flight (react-server-dom-webpack/server) — クライアントサイドでの再構築に向けて、React ツリーをバイナリペイロードにシリアライズする

ストリーム操作は stream-ops.ts に抽象化されており、内部では stream-ops.web.ts を再エクスポートしています。主な操作は次のとおりです。

  • renderToFizzStream — コンポーネントツリーを HTML にレンダリングする
  • renderToFlightStream — クライアントナビゲーション用に RSC データをシリアライズする
  • createInlinedDataStream — Flight データを <script> タグとして HTML ストリームに注入する
  • continueFizzStream — Suspense バウンダリの解決とストリーミングを処理する
sequenceDiagram
    participant App as React Tree
    participant Flight as Flight Renderer
    participant Fizz as Fizz Renderer
    participant Transform as Stream Transform
    participant Client as Browser

    App->>Flight: Serialize RSC tree
    Flight-->>Fizz: React elements (with client refs)
    Fizz-->>Transform: HTML chunks
    Flight-->>Transform: Flight data chunks
    Transform->>Transform: Interleave HTML + <script> tags
    Transform->>Client: Streaming response
    Note over Client: Browser renders HTML immediately
    Note over Client: Hydration uses inlined Flight data

初回ページロードでは、HTML ストリームと Flight データが組み合わされます。Flight データは createInlinedDataStream によってインライン <script> タグとして HTML に埋め込まれるため、ブラウザは追加のリクエストなしにハイドレーションを完了できます。これが「自己完結型 HTML」のアプローチです——ハイドレーションに必要な RSC データが、同じレスポンスの中で HTML と一緒に届きます。

クライアントナビゲーションの場合は、Flight ストリームのみが送信されます。クライアントサイドルーターが RSC データを直接コンポーネントツリーに適用するため、HTML は不要です。

Partial Prerendering(PPR)

PPR はレンダリングエンジンの中で最も野心的な機能です。レンダリングを 2 つのフェーズに分割します。

  1. 静的シェル — ビルド時にプリレンダリングされ、静的なコンテンツと動的セクションのための Suspense フォールバックを含む
  2. 動的な穴 — リクエスト時に埋められ、フォールバックを置き換える動的なコンテンツをストリーミングする

dynamic-rendering.ts モジュール(約 1,395 行)は、動的アクセスのトラッキングを管理しています。静的プリレンダリング中に Server Component が headers()cookies() などの動的 API を呼び出すと、動的レンダリングシステムが React の postpone() API を使って該当の Suspense バウンダリを動的としてマークします。

stateDiagram-v2
    [*] --> StaticPrerender: Build time
    StaticPrerender --> FullyStatic: No dynamic access
    StaticPrerender --> PPRShell: Dynamic access detected

    PPRShell --> SerializePostponed: React.postpone()

    state "Request Time" as RT {
        [*] --> LoadShell: Serve cached static HTML
        LoadShell --> ResumeRender: Fill dynamic holes
        ResumeRender --> StreamDynamic: Stream dynamic content
    }

    SerializePostponed --> RT: On request

    FullyStatic --> ServeCached: Serve from cache

postponed-state.ts モジュールは、postponed state のシリアライズ形式を定義しています。2 種類があります。

  • DynamicState.DATA — RSC レンダリング中に動的アクセスが発生したケースです(例:Server Component 内での cookies() の読み取り)。サーバーは RSC ツリーを再レンダリングする必要があります。
  • DynamicState.HTML — HTML 生成中に動的アクセスが発生したケースです(例:動的データに依存する Suspense バウンダリ)。サーバーは postpone ポイントから Fizz レンダリングを再開できます。

各 postponed state には RenderResumeDataCache が含まれています。これは静的プリレンダリング中にすでに計算されたデータで、再開時に再利用できます。resume data cache は server/resume-data-cache/ に格納されており、プリレンダリング中にキャプチャされた RSC ペイロードの断片とフェッチキャッシュエントリの両方を保持しています。

補足: PPR は experimental.ppr の設定で制御します。有効にすると、ビルド時のプリレンダリングでは renderToString() ではなく React の prerender() API が使われます。prerender()postpone() をサポートしており、ランタイムでは resume() を使って request 固有のデータでレンダリングを完了させます。

レンダリング判断マトリクス

これらをまとめると、レンダリングエンジンはすべてのリクエストに対して次のような判断を連鎖的に行っています。

条件 レンダリング戦略
初回ロード、postponed state なし 完全な Fizz HTML ストリーム + インライン Flight データ
初回ロード、postponed state あり(PPR) 静的シェルを配信し、動的な穴を再開
RSC リクエスト(クライアントナビゲーション) Flight のみのペイロード
RSC プリフェッチリクエスト 部分的な Flight ペイロード(レイアウトのみ、リーフデータなし)
静的生成(ビルド時) postpone を伴う可能性のあるプリレンダリング
ISR の再検証 バックグラウンドで再レンダリング、古いデータを配信

レンダリングエンジンの複雑さは、これらすべてのケースを単一のコードパスで処理しなければならないことに由来します。renderToHTMLOrFlightImpl はパースされたリクエストヘッダーと work store の状態を基に適切な戦略へ分岐しますが、コンポーネントツリーの構築、エラーハンドリング、ストリーム管理はすべてのパスで共有されています。

次回に向けて

サーバーが App Router ページをレンダリングする仕組み(コンポーネントツリーの構築、HTML か Flight かの選択、PPR パイプラインの統制)を見てきました。では、これらの Flight ペイロードがブラウザに届いた後、何が起きるのでしょうか。次回はクライアントサイドルーターを掘り下げます。ナビゲーション、レイアウトの保持、そして PPR プリフェッチを可能にするセグメントキャッシュを管理する Redux ライクなステートマシンの全貌に迫ります。