Open10

Next.js@13 と React Server Component と Suspense と GraphQL の @defer, @stream について

YutaUraYutaUra

注:このスクラップの内容は間違いとか勘違いとか含んでる可能性があります。
間違いの指摘や補足など大歓迎です。

YutaUraYutaUra

React Server Component と Suspense と Next.js@13 ってなんか紛らわしくね

React Server Component(RSC)

rfc

概要

  • RSC はサーバー上で実行されるコンポーネントのこと
  • サーバー上で実行されるということで、データベースへの通信などを直接行うことができる
  • コンポーネントはレンダリング結果のみがクライアントに送られるので、秘匿情報などの流出は起こらない

適当に具体例を書くと

// 従来の方法(React Client Component, RCC とする)
const Page = () => {
  const { data } = useSWR("/todos/1", () => fetch("/todos/1"))
  // 
}

// RSC
const Page = async () => {
  const todo = await db.query("select * from `todo` where id == 1")
  // 
}

みたいな感じで書くと、サーバでデータベースへのアクセスが行われて、レンダリングするために必要な情報だけがクライアントに送信されるみたいなイメージ

特徴としては RSC では async await を使うことがサポートされている点

現時点で RCC では async await を使うことはできない(後述 [1])が、データを取得するまでレンダリングを中断させてしまうと画面に何も表示できないからである。

ただ Suspense を利用することで、RCS でも部分的にレンダリングを中断するということが可能になっている。また RSC と Suspense の関係については rfc のこの箇所 に言及がある

話を戻して、RSC で async await を利用するということはデータの取得が完了するまでユーザーにHTMLを返せないということになりそうだが、実際はうまいこと実装がされているっぽい

実際に Next.js@13 で意図的に await new Promise((resolve) => setTimeout(resolve, 1000)) こういう感じで待機の処理をしても、そのコンポーネント以外はすぐに結果が帰ってくる

脚注
  1. RCC と async await に関する rfc https://github.com/acdlite/rfcs/blob/first-class-promises/text/0000-first-class-support-for-promises.md ↩︎

YutaUraYutaUra

再利用可能な RSC と async await

前述した通り、Next.js@13 では async await を利用したコンポーネント定義が可能となっている。

const Page = async () => {
  const todo = await db.query(/* ... */)
  //
}

しかし、 async await で定義した RSC のコンポーネントを利用することはできない。(っぽい?)

const Child = async () => <p>child</p>

const Page = async () => {
  return (
  // 'Child' cannot be used as a JSX component.
  //  Its return type 'Promise<Element>' is not a valid JSX element.
  //    Type 'Promise<Element>' is missing the following properties from type 'ReactElement<any, any>': type, props, keyts(2786)
    <Child />
  )
}

その場合は use を使えばよい

import { use, Suspense } from "react"

const ChildRSC = () => {
  const todo = use(db.query(/* */))
  //
}

const Page = async () => {
  return (
    <>
      {/*  */}
        <Suspense fallback={<div>Loading...</div>}>
          <ChildRSC />
        </Suspense>
      {/*  */}
    </>
  )
}

この時、Suspense を使っていないと、 Page 自体のレンダリングも遅延されてしまうことには注意が必要

お気持ち

ここの設計は正直美しくないですよね。
個人的に fallback や Error のハンドル義務を上流に移譲することは大賛成なのですが、コンポーネント自体が Suspense すべきかどうかを型など表現できていないことには上流でハンドルが必要かどうかがわからないのでどうかなぁと思います。

https://github.com/acdlite/rfcs/blob/first-class-promises/text/0000-first-class-support-for-promises.md が導入されて Suspense すべきかどうかを Promise を返しているかどうかで判定するみたいな感じでいい感じに仕組み化できることに期待したいですね

YutaUraYutaUra

ここまでで 個人的に Suspense と RSC について考えをまとめることができたので、次は GraphQL の @defer との組み合わせについて考えていく

YutaUraYutaUra

GraphQL の @defer とは

詳細な説明については別の記事等を参照ください。
めっちゃ簡単に説明すると、GraphQL のレスポンスを分割して、部分的に返すよみたいな感じです

query TopPage {
  # インラインフラグメントを用いてますが、普通にフラグメントにしても大丈夫です。
  ...on Query @defer {
    # 通知バッジに表示する通知数を取得する処理がめっちゃ重いとする
    notificationCount
  }
  # ... その他にも取得したいものがある
}

こうした時に

# まずはその他のデータが帰ってくる
{
  data: {
    // ...
  }
}
# その後追加で notificationCount が帰ってくる
{
  data: {
    notificationCount: 10
  }
}

みたいな感じ(本当はもう少しちゃんとしてる)。現在実装されている方法としては content-type: multipart/mixed を利用されているのがある。

ここまで聞いて察しの良い方は既にお気づきかもしれないが、 @deferSuspense を組み合わせると、データの遅延取得とコンポーネントの遅延レンダリングを組み合わせることで、大規模なWebページも逐次的なレンダリングを効率の良い方法で実現ができそうだと感じるわけである。

YutaUraYutaUra

Suspense@defer についての課題

上記でコメントした内容を実現させるには課題がある。

まず一つ目として、そもそも @defer を適切に扱える GraphQL Client が少ないのである。

  • @apollo/client3.7以降で @defer の利用が可能となっている
  • relay はまだ @defer のサポートがされていない
  • urql はサポート済み

ただ urql に関しては request headers の acceptmultipart/mixed を追加してくれていないため、僕の環境ではうまく動かすことができなかったのと、コードをいじって multipart/mixedaccept に追加してもやっぱり動かなかったので、正直ちょっとわからんって感じです

そして次の課題として、@defer の正しい扱いが非常に難しいということである

具体的にどういうことかというと、@deferSuspense を組み合わせて使う上で、以下ができるということは求めてはいない。

const Page = () => {
  const { data, loading } = useQuery(gql``)
  
  if (loading) {
    // @defer を含めデータの取得が完了していない時
    return `loading`
  }
  // @defer を含めデータの取得が完了している状態
}

上記では、せっかく @defer で遅延読み込みが可能になっても、レンダリングのタイミングが同時となっては全く意味がないのである。

なので、本当に求めているのは例えば次のようなコードである

const NotificationCountFragment = gql`
  fragment NotificationCount on Query {
    notificationCount
  }
`

const PageQuery = gql`
  query Page {
    ... NotificationCount @defer
  }
  ${NotificationCountFragment}
`

const NotificationBadge = ({ getFragment }) => {
  const { data } = use(getFragment(NotificationCountFragment))
  
  return (
    <div>
      count: {data.notificationCount}
    </div>
  )
}
const Page = () => {
  const { getFragment } = query(PageQuery)

  return (
    <>
      <Suspense fallback={"loading..."}>
        <NotificationBadge getFragment={getFragment} />
      </Suspense>
    </>
  )
}

みたいな感じである。

こんな感じであれば、Suspense@defer を使った逐次レンダリングが可能となる

YutaUraYutaUra

@defer と Server Side Rendering の課題

Server Side Rendering については今回初めて言及したが、読者の方は既にどんな存在であるか理解しているかと思うので、説明は省く。

Server Side Rendering で適切にレンダリングした後、クライアントで hydration を行う際の初期データをどのように届けるかという問題がある。

Server Side Rendering で query を行い、hydration の際にも query を行えば問題はないだろうが、サーバーで実行しているクエリの結果をクライアントで送れたほうが効率的であるし、もしサーバーで実行した時とクライアントで実行した時で結果が異なるとしたら、hydration に失敗したりしそう(本当かは知らない)

よくある既存のSSRでの初期データ埋め込みがどのように実装されているかというと

  1. SSR の段階で発生した GraphQL リクエストをひとまずキャッシュに入れておく
  2. 必要な GraphQL リクエストが全て完了したらその結果を <script> タグの中に埋め込む

簡易的な例としては

const Page = async () => {
  const client = new GraphQLClient()

  const element = (
    <GraphQLProvider value={client}>
      <App />
    </GraphQLProvider>
  )

  // レンダリングを行う過程で発生する GraphQL リクエストが終了するのを待つ
  await waitForGraphQL(element)

  return (
    <>
      {element}
      <script>
        {/* 実際は dangerouslySetInnerHTML を使う必要がある */}
        window.__GRAPHQL_INITIAL_DATA = JSON.parse(${JSON.stringify(client.getData())});
      </script>
    </>
  )
}

みたいな感じである。(もしかしたら全然違う実装もあるかもしれない

ここでもやっぱり @defer を使った遅延読み込みを使った際に、それらを逐次的にクライアントに送ったほうが良さそうに感じる。

もしかしたら、@defer で遅延された結果が返ってきたタイミングごとに script タグを配信するみたいな方法が取れるかもしれない

YutaUraYutaUra

前述の getFragment フラグメントの利用箇所が1つだけで配列でなければ、どのフラグメントの値を求めているか判断できるけど、そうじゃなければどのフラグメントの値を求めているか判別するの難しそう

fragment ProductPrice1 on Product {
  price
}

fragment ProductPrice2 on Product {
  price
  relatedProducts {
    id
    ...ProductPrice1 @defer
  }
}

query Page {
  products {
    id
    ...ProductPrice2 @defer
  }
}

こういうクエリの時 getFragment(ProductPrice2Fragment) はどの ProductPrice に対応する Promise を返せば良いのだろうか

GraphQL のレスポンスには path が含まれているので、レスポンスからどのフラグメントに対するデータかの判断は可能である

{
  "incremental": [
    {
      "data": {
        "price": 1000,
        "relatedProducts": [
          { "id": "123" },
          { "id": "234" }
        ]
        "__typename": "Product"
      },
      "path": ["products", 0, "relatedProducts", 0]
    }
  ],
  "hasNext": true
}

コンポーネント側の実装

const Product = ({ getFragment }) => {
  // この時 data はどれに対するデータなのか?
  const { data } = use(getFragment(ProductPriceFragment))

  console.log("price", data.price)
}

事前に path がわかりきっているとすれば

const Product = ({ getFragment }) => {
  // path を指定することで、特定できる
  const { data } = use(getFragment(ProductPriceFragment, ["products", 0, "relatedProducts", 0]))

  console.log("price", data.price)
}

ってできるけど、配列のアイテムとかだと厳しいよね

YutaUraYutaUra

こんな風にすればいいのかな?

const ProductChild1 = ({ getFragment, path }) => {
  // path を指定すれば特定できる
  const { data } = use(getFragment(ProductPriceFragment1, path))

  console.log("price", data.price)
}

const ProductChild2 = ({ getFragment, path }) => {
  const { data } = use(getFragment(ProductPriceFragment2, path))

  return (
    <div>
      {data. relatedProducts.map((product, i) => (
        <ProductChild1 key={product.id} getFragment={getFragment} path={[...path, "relatedProducts", i]} />
      ))}
    </div>
  )
}

const Page = () => {
  const { data, getFragment } = query(PageQuery)

  return (
    <div>
      {data.products.map((product, i) => (
        <ProductChild1 key={product.id} getFragment={getFragment} path={["products", i]} />
      ))}
    <div>
  )
}
YutaUraYutaUra

でもわざわざ pathprops として引き回すのめっちゃめんどくさい気がするけど、どうにかならんかな