tomoima525's blog

Androidとか技術とかその他気になったことを書いているブログ。世界の秘密はカレーの中にある!サンフランシスコから発信中。

Apollo Client v3 アップデートのハマりどころまとめ

f:id:tomoima525:20201013164807p:plain

先日会社のプロジェクトにて、ようやくApollo Clientをv3にアップデートしました。今回のメジャーアップデートはいくつか大きな変更点があり、我々のプロジェクトでは影響を受けたファイルは合計300以上になりました。この記事ではどのような変更があったのか、はまりポイントはどこなのか、かいつまんで紹介します。

Apollo v3 の Breaking Change 概要

まずは v3における Breaking Changeについて、多くのプロジェクトが影響を受けるであろう主要な点についてまとめてみました。 完全な Change Logはこちらを御覧ください(長大です)

https://github.com/apollographql/apollo-client/blob/v3.0.0-rc.0/CHANGELOG.md

パッケージ構成の変更

まず、パッケージ構成が大きく変わりました。ほとんどの機能が @apollo/clientの配下に移行しています。そのためいままで個別にインストールしていたパッケージはまるごと削除できます。ライブラリの依存関係がシンプルになるという点で素晴らしい更新ですね。

react-hoc などいくつかのライブラリが非推奨に

続いて、いくつかのメジャーなライブラリが retired しました。react-hocは結構使われていたと想像しますが、hooksが全盛の今、その役割が終わることは必然でしょう。apollo-boost もさよならです。

個人的に残念なのは、 experimental で入っていた @defer がv3からはなくなってしまったことでしょうか。一応まだ戻ってくる可能性があるかもというissueも立ってますが、GraphQLの仕様においてもProposal段階*1なので、まだ当分先になるんでしょうね。

キャッシュ機構の変更

おそらくv3で一番変更があったのが、キャッシュ周りでしょう。ざっくりとまとめると以下が変更点です。

★Type Policy APIによる厳格なキャッシュ管理
v2までのApolloではキャッシュが非効率に使われたり肥大化することでパフォーマンス問題が引き起こされていました。これを解決するために、Type Policy APIという機能が導入され、厳密にキャッシュの更新や追加が行われるようになりました。
ひとつ例を通して新しいキャッシュの仕組みについて説明します。同じ__typenameを持ち、サーバー上は同じデータ元ながら、クライアント側では違うデータ構造を持つ以下の2つのObjectがある場合、片方が更新された場合にもう片方に変更は伝搬されるでしょうか?

Book {
  author {
    language
  }
  title
}

Book {
  author {
    name
  }
  title 
}

答えはNOです。v3では。v2ではこのような非正規なデータであっても更新されるケースもあります(開発者は制御できません)。適切に結果をマージしたい場合は新しく導入された Type Policy APIを使って明示的にどう2つのデータをマージするか定義する必要があります。

例えば2つの結果を同じものとしてキャッシュしたい場合は以下の様にtypePoliciesに書きます。

const cache = new InMemoryCache({
  typePolicies: {
    Book: {
      fields: {
        author: {
          merge(existing, incoming) {
            return mergeObjects(existing, incoming); // 新しい結果を統合して更新
          },
        },
      },
    },
  },
});

なおmergeObjects は 上の例で authorの内部のfieldがさらにmergeの定義を持っていても適切にマージを行ってくれるApolloのユーティリティ関数です。

もう少し詳細はこちらに書いてあります。

www.apollographql.com

Type Policy APIはこの他にもObjectのkeyの設定やキャッシュの読みこみ時の値の変更など、幅広く使われるCacheのコアとも呼べる機能となっています。

★キャッシュのInvalidation
その他の便利な機能として、cache.evictが導入されました。これで不要になったキャッシュは捨てることができるようになり、より効率的にキャッシュを扱うことができるようになっています。

★Local Resolverの廃止
v2までは@clientapollo-link-stateに受け渡しクライアント側のローカルなリゾルバーを定義することができました。v3からはApolloClientのresolversをつかうことが必須となりました。

apollo-link-stateは元々ネットワークリクエストを介して副作用を起こす機構であるのに対し、@clientはネットワークを介さないローカルステート管理であり、用途が一致しないので、ApolloClientで一元管理する方向性にしたいという思惑があったようです。ともかく、resolversに集約されたことで、管理は非常にシンプルになりました。

★FragmentMatcherの廃止 => possibleTypesへ
これまでUnion TypesをもつFragmentはFragmentMatcherで合致するFragmentを定義していましたが、今回新たにpossibleTypesという設定が導入されました。FragmentMatcherと比べて遥かにシンプルでコード量が減っています。

★その他 なお、ユーザー側実装に関わる内容ではないですが、キャッシュの読み書きの速度については 37% - 76%改善されている そうです

はまりポイント

さて、以上を踏まえた上で、自分がアップデートの過程でハマったことを書いていきます。あ、その前に、まずはこのマイグレーションガイドによく目を通すことをおすすめします!

www.apollographql.com

useQueryuseMutationで無限ループ

油断すると簡単にuseQueryuseMutationで無限ループが発生するようになりました。無限ループに陥ったときにまず気をつけたいのがuseQueryなどに渡す値が変わっていないかという点です。我々の場合では、APIの不具合があった場合にサーバー側でトラッキングする目的で、BaseQueryOptionのContextにリクエストごとにランダムなuuidを生成し、挿入していました。この状態で fetchPolicycache-firstcache-only以外を設定すると簡単に無限ループに陥りました。

  const operationId = uuidV4();
  const context = { operationId, queryVariablesName };
  const { data, ...rest } = useQuery(document, {
    context,
    variables,
  });

対処法としては、Functional Componentがマウントしている間はuseMemoで同じuuidを渡すようにして、optionも変わらないようにしました。

  const operationId = useMemo(() => uuidV4(), []);
  const context = { operationId, queryVariablesName };
  const { data, ...rest } = useQuery(document, {
    context,
    variables,
  });

そもそもの原因としては、Optionsの差分チェックで厳密にOptionsのアップデートをチェックしていることにあるようです。

なお、似たような事象はApolloのissueでもいくつもレポートされています。
useCallbackなどでmemoizedされた値を渡す、nextFetchPolicy: 'cache-first' を設定して、二回目のリクエストが発生しないようにすることなどが対処法として挙げられています。

https://github.com/apollographql/apollo-client/issues/6301 https://github.com/apollographql/react-apollo/issues/4008

キャッシュ更新のtypePolicy定義

前述の通り、idを持たない非正規なObjectや異なるfieldを持つObjectはtypePoliciesを定義してやらないと、正しくキャッシュが更新されなくなります。問題はキャッシュ更新ができていないかどうかが判断できないことです。
consoleにwarningが出るケースもあるのですが、そうでないケースがほとんどです。設定が適切でないと、例えばページングが動かなくなったりします(新しいデータが追加されず上書きされてしまうため)。
ひとつひとつ虱潰しに機能を確認してキャッシュが適切に更新されているかを確認するしか術がありませんでした。

我々に起きたバグとしては

  • 他のユーザー画面を開いたあとに自分のユーザー画面を開くと結果が上書きされる
  • ページングが動かない

などです。ビューに表示される値がレスポンスに合わず変になった、などがあればまずType Policyを疑うべきでしょう。

testでの graphql-tag呼び出し

これはもしかすると我々のプロジェクト固有のケースなのかもしれないですが、jestを走らせた際に、コード内で graphql-tagが使われていると、TypeError: (0 , _client.gql) is not a function というエラーメッセージでTestがコケました。全く原因がわからず頭を抱えていたのですが、importを

import { gql } from '@apollo/client';

から

import { gql } from '@apollo/client/core';

に変えたところ、無事動くようになりました。coregqlの本体があるので、re-importしたものを参照したことで起きた不具合かもしれません。

まとめ

今回のメジャーアップデートは動作確認も含めた対応に1週間ほどかかりました。良かったこととしては、v2とv3はパッケージが異なるので共存ができるという点です。まずは migrationガイドをよく読んで、インポート周りの修正から始めるのがおすすめです。

*1:deferについてはこれが詳しい https://summit.graphql.com/slides/directives.pdf