空雲 Blog

Eye catch@apollo/clientのuseSuspenseQueryでSSR

publication: 2022/12/18
update:2024/02/20

@apollo/client の useSuspenseQuery に関して

2022/12/10 の 3.8.0-alpha.0 から使えるようになった新機能で、GraphQL の query が Suspense 対応になったものです。Alpha 版でも実験的な扱いになっており、インポートする際はuseSuspenseQuery_experimentalという名前になっています。

具体的な実装状態は以下を参照してください

https://github.com/apollographql/apollo-client/issues/10231

Suspense と SSR と ServerComponents

Suspense を前提とする fetch 内包 hook をサーバ上で使用する上での注意点があります。サーバー側で Promise を Suspense で待つと、初期 HTML の出力がStreaming SSRになります。そして Streaming した HTML データには fetch で受け取ったデータがレンダリングされているものの、クライアント側のコンポーネントには受け渡しされません。もしクライアント側にデータを受け渡して再レンダリングを行う場合は、ServerComponents を利用する必要があります。

ただ、ServerComponents の仕組みを使うのは、クッソ使いにくいのでお勧めできません。HTML 出力時に時に初回完成版状態で吐き出して、その後にユーザからの入力で内容を書き換えるということが出来ないからです。初期レンダリングに含まれる部分(ServerComponent)は後から改変できず、変更可能な部分(ClientComponent)は初期レンダリングに含まれません。

ということで今回は ServerComponents は使用しません。データ引き渡しの動作を自分で実装し、従来型の SSR で動くようにします。

Next.js と@apollo/client で SSR する場合の従来の方法

古くから使用されているのが getDataFromTree を使い、いったんサーバ上でコンポーネント動作させキャッシュを構築し、それを使って本レンダリングするという 2 パスの方法です。一回仕組みを作れば、あとはコンポーネント上にクエリを書けば済みます。ただ 2 パスなので、レンダリングが無駄に発生します。

もう一つは getServerSideProps を利用して、ページごとに初期データを用意するという形です。コンポーネントのクエリとは別に fetch 処理を書かなければならないので、それなりに面倒です。また、遷移時の再 fetch に関して、クライアントにキャッシュがあればやらないというような制御が出来ないという問題もあります。

今回の方法

useSuspenseQuery を従来の SSR と同じように動作させつつ、getDataFromTree の 2 パスも使わず、getServerSideProps で fetch を別処理として書かなくて済むようにします。

動作原理としては useSuspenseQuery で発生する Promise を収集し、非同期処理が全て終了するまで Next.js を待機させ、Streaming させずに完成版の HTML を吐き出すようにします。

サンプルソース

  • src/libs/apollo-ssr.tsx

SSRCache が@apollo/clientの SuspenseCache を継承して、クエリで発生する promise を収集しています。また、サーバで生成されたキャッシュデータをクライアントに受け渡す機能を実装しています。

SSRProvider が useSuspenseQuery で生成された Promise の待機と、ApolloCache のクライアントへの引き渡し処理を行います。

import { ApolloClient, DocumentNode, getApolloContext, ObservableQuery, OperationVariables, SuspenseCache, TypedDocumentNode, useApolloClient, } from "@apollo/client"; import { ReactNode, useContext } from "react"; export class SSRCache extends SuspenseCache { add<TData = any, TVariables extends OperationVariables = OperationVariables>( query: DocumentNode | TypedDocumentNode<TData, TVariables>, variables: TVariables | undefined, { promise, observable, }: { promise: Promise<any>; observable: ObservableQuery<TData, TVariables>; } ) { this.promises.add(promise); return super.add(query, variables, { promise, observable }); } finished = false; promises = new Set<Promise<unknown>>(); } const DATA_NAME = "__NEXT_DATA_PROMISE__"; const DataRender = () => { const client = useApolloClient(); const context = useContext(getApolloContext()); const cache = context.suspenseCache; if (!(cache instanceof SSRCache)) { throw new Error("SSRCache missing."); } if ( typeof window === "undefined" && !cache.finished && process.env.NEXT_PHASE !== "phase-production-build" ) { throw Promise.all(cache.promises).then((v) => { cache.finished = true; return v; }); } return ( <script id={DATA_NAME} type="application/json" dangerouslySetInnerHTML={{ __html: JSON.stringify(client.extract()) }} /> ); }; export const initApolloCache = <T,>(clinet: ApolloClient<T>) => { if (typeof window !== "undefined") { const node = document.getElementById(DATA_NAME); if (node) clinet.restore(JSON.parse(node.innerHTML)); } }; export const SSRProvider = ({ children }: { children: ReactNode }) => { const client = useApolloClient(); initApolloCache(client); return ( <> {children} <DataRender /> </> ); };

  • src/pages/_app.tsx

ApolloProvider の中に SSRProvider を入れています。これでサーバ上の useSuspenseQuery の取得データが自動的にクライアントに渡されるようになります。
uri は Vercel で動作可能なように調整しています。

また、getInitialProps 自体は使っていないのですが、これを入れないと App 部分がビルド時に静的最適化が行われて、クライアントからの接続時に実行が省略されてしまうので、その対策として入れています。

import type { AppProps } from "next/app"; import { Suspense } from "react"; import { ApolloClient, ApolloProvider, InMemoryCache } from "@apollo/client"; import { SSRCache, SSRProvider } from "../libs/apollo-ssr"; const endpoint = "/api/graphql"; const App = ({ Component, pageProps }: AppProps) => { const client = new ApolloClient({ uri: typeof window === "undefined" ? `${ process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : "http://localhost:3000" }${endpoint}` : endpoint, cache: new InMemoryCache(), }); return ( <ApolloProvider client={client} suspenseCache={new SSRCache()}> <SSRProvider> <Suspense fallback={"Loading"}> <Component {...pageProps} /> </Suspense> </SSRProvider> </ApolloProvider> ); }; // getInitialProps自体は必要としていないが、_app.tsxの最適化防止のために必要 // これを入れないとbuild時にApp部分が実行されて、それ以降内容が呼び出されなくなる App.getInitialProps = () => ({ pageProps: {} }); export default App;

  • src/pages/index.tsx

現在の日時を受け取るクエリを呼び出しています。
初回はサーバ側で実行されて SSR されますが、クライアント側で refetch を呼び出した場合は、クライアントの処理となります。

import { gql, useSuspenseQuery_experimental as useSuspenseQuery, } from "@apollo/client"; // 日付を取り出すQuery const QUERY = gql` query date { date } `; const Page = () => { const { data, refetch } = useSuspenseQuery(QUERY); return ( <> <button onClick={() => refetch()}>Reload</button> <div> {data?.date && new Date(data.date).toLocaleString("ja-jp", { timeZone: "Asia/Tokyo", })} </div> </> ); }; export default Page;

まとめ

useSuspenseQuery で使用されている suspenseCache に少々手を入れることで、無駄のない SSR が出来るようになりました。ServerComponents のような制約もないので、初期 HTML の内容は変更可能です。また、コンポーネントにクエリを直接書くだけで済むので、getServerSideProps でページごとの初期データを用意する必要もありません。getDataFromTree も使用していないので、無駄な 2 パスレンダリングもありません。

問題があるとすると、useSuspenseQuery がまだ Alpha 版だということです。