KAKEHASHI Tech Blog

カケハシのEngineer Teamによるブログです。

@apollo/clientの3.8.xへのアップデートに伴う挙動変更

はじめに

AI在庫管理のフロントエンド開発をしている木本です。

先日、@apollo/clientのv3.7.17からv3.8.1へのアップデートに伴う大規模なデグレが発生しました。具体的には無限スクロールで情報を取ってくる画面で、一切情報が見られなくなってしまう状態となりました。一次対応として@apollo/clientをv3.7.17に戻すことでデグレは解消されました。

この@apollo/clientアップデートについて、情報があまりネット上に広まっていないようなのでブログ記事として情報を残したいと思います。

TLDR

@apollo/clientをv3.7をv3.8以降にアップデートしてfetchMore周りに問題が発生したときは、useQuerynotifyOnNetworkStatusChange: trueを入れると動く(かも)。

デグレの原因

@apollo/clientはGraphQLのAPIアクセスを軸として、状態管理やページング機能を組み込んだ高機能なGraphQLクライアントライブラリです。

このページング機能が原因で問題が発生していました。次のようなコードが原因でした。

const [loading, setLoading] = useState(true);

const { data, fetchMore, error, loading, refetch } = useQuery<
  Document,
  { offset: number; limit: number }
>(query, {
  client,
  onCompleted: async (d) => {
    if (d.result.pageInfo.hasNextPage) {
      fetchMore({ variables: { cursor: d.result.pageInfo.nextCursor } });
    } else {
      setLoading(false);
    }
  },
});

こちらのコードは、onCompletedfetchMoreを動かすことで、ページングされている情報の取得を一気に行うことを意図しています。pageInfo.hasNextPagetrueならば、pageInfo.nextCursorfetchMoreが動くという意図です。 こちらのコードはv3.7.17までは動いていましたが、v3.8.0以降は動きません。

こちらが例です(Special Thanks: 江藤さん)。

取得中はローディング画面としておき、最終的にhasNextPagefalseになったのを検知してloadingfalseに戻すのですが、このloadingはいつまで経ってもfalseにならないため、ずっとローディング画面のままとなる障害が発生してしまいました。

なぜこのコードは動かなくなったのか

onCompletedfetchMoreが動かなくなるという問題はこちらのIssueで報告されています。

Apolloのメンバーであるsmyrickさんの返答がこちらです。

Hey everyone, I talked with the Apollo Client team and have an update: (みなさんこんにちは。Apollo Clientのチームと話してきました)

From the AC maintainers, this has been one of those issues where one group of people think the old behavior was a bug, but other groups of people think this new behavior is a bug, so we chose a path forward that at least has a solution for both groups, even if that requires code changes. (Apollo Clientのメンテナからするとこの問題は、あるグループは旧挙動をバグだと思っているが別のグループは新挙動をバグだと思っているタイプの問題とのこと。だから我々は少なくとも両方のグループに必要な解決策を提供しなければならないと思っている。ユーザー側のコード修正が必要な解決策となるにしても。)

See this PR for more info on the behavior: #10229 (この挙動についての情報はこちらのPRを確認してください:)

<後略>

というわけで

  • @apollo/clientチームは旧挙動をバグだと思っている。
  • この変更はbreaking changeではあるが、一応解決策は用意されている

という状況です。そのため、この問題の将来的な修正は見込まれないと思われます。

実際に該当PR(#10229 fix(regression): avoid calling ``useQuery``\ ``onCompleted`` for cache writes)を見てみると、一部の条件でonCompletedが動かないように修正されています。

-  private handleErrorOrCompleted(result: ApolloQueryResult<TData>) {
+  private handleErrorOrCompleted(
+    result: ApolloQueryResult<TData>,
+    previousResult?: ApolloQueryResult<TData>
+  ) {
    if (!result.loading) {
      // wait a tick in case we are in the middle of rendering a component
      Promise.resolve().then(() => {
        if (result.error) {
          this.onError(result.error);
-        } else if (result.data) {
+        } else if (
+          result.data &&
+          previousResult?.networkStatus !== result.networkStatus &&
+          result.networkStatus === NetworkStatus.ready
+        ) {
          this.onCompleted(result.data);
        }
      }).catch(error => {

該当のdiff

v3.7.17以前の処理フロー
v3.8.0以降の処理フローでバグが起きる様子

変更前(v3.7.17以前)はresult.dataが入ってさえすればonCompletedが実行されていたのに対し、変更後(v3.8.0以降)はresult.networkStatusが変化してreadyに変わることが必要となっています。

こちらの変更の理由としては、キャッシュへの書き込みによってもonCompletedが実行されてしまうという問題を解決したいとのことです。

There are a few related issues pointing to a regression that occurred between v3.4.17 and v3.5.0 and greater: writes to the cache (it doesn't matter how the cache update occurs: directly via cache.writeQuery, via a separate useMutation call, etc.) trigger the onCompleted callback passed to useQuery, whereas in v3.4.xonCompleted was triggered when the initial network request completed, and not on subsequent cache writes.

具体的にはcache.writeQueryuseMutationが上げられており、fetchMoreでもcache.writeQueryを内部で使用していました。たしかにこれが原因でクエリのonCompletedがトリガーされてしまう事態はバグと言えます。そのため、キャッシュが直接変更されたことを検知するためにnetworkStatusの監視を行う形に修正されたようです。ただ、その修正によりfetchMoreの実行でもonCompletedが実行されなくなってしまう問題が生じているという状態とのことです。

解決法

開発者はPRを提出した段階でこの問題を把握していたようで、解決法としてuseQuerynotifyOnNetworkStatusChange: trueというプロパティを差し込むことを提案しています。

const { data, fetchMore, error, loading, refetch, networkStatus } = useQuery<
  Document,
  { offset: number; limit: number }
>(query, {
  client,  
  notifyOnNetworkStatusChange: true,
  onCompleted: async (d) => {
    if (d.result.pageInfo.hasNextPage) {
      fetchMore({ variables: { cursor: d.result.pageInfo.nextCursor } });
    }
  },
});

確かにこれで動きました(例ページではnotifyOnNetworkStatusChangeの指定をチェックボックスで変更できます)。

notifyOnNetworkStatusChangeは通信状況の監視を行うフラグで、trueに設定すると通信状況が変動するたびにre-renderしてくれるようになります。監視されている通信状況の状態はnetworkStatusというstateに保存されているので、こちらを覗くことでnetworkStatusの状態の変動を確認できます。

さきほどのパッチでは、こちらのnetworkStatusonCompletedのトリガー判定に利用されています。ドキュメントの記載からはnotifyOnNetworkStatusChange: trueを指定しないとnetworkStatusundefinedのまま固まるように読めますが、実際はそんなことはなく、false時であっても内部的にいろいろ変動しており、その変動の仕方がnotifyOnNetworkStatusChangeのbool値によって変わってきます。

notifyOnNetworkStatusChangefalse時には、fetchMoreが実行されてもnetworkStatusreadyのまま固定されます。しかしnotifyOnNetworkStatusChangetrueのときはnetworkStatusfetchMoreという値に切り替わります。

アップデート後、networkStatusの値の変動が発生しなかった場合onCompletedがトリガーされることがなくなりました。そのため、notifyOnNetworkStatusChangetrueに入れる必要が出てきてしまったという形です。

v3.8.0以降の処理フロー

AI在庫管理では、最終的にこのnotifyOnNetworkStatusChangeを修正することで@apollo/clientを無事にアップデートしています。

semantic versioning 上正しいのか?

major versionを更新せずにBreaking Changeを入れることはsemantic versioningを破っているように見えます。@apollo/clientCHANGELOGを見てみると、minor updateでもBreaking Changeがバンバン入っています。 やはりこの件についてはツッコミが入っていたので見てみると、

I agree with the points you've raised: we generally avoid including non-backward compatible bug fixes in patch/minor releases, but we've made exceptions depending on the nature of the regression. (ご指摘の点には同意します。通常、後方互換性がないバグフィックスをパッチ/マイナーリリースとしてリリースすることは避けていますが、リグレッションの性質によっては例外としています)

と返答されています。確かに、現在@apollo/clientのmajor versionは3となっていますが、後方互換性がないリリースを今まで2回しかしていないとはとても思えません。

そのため、そのライブラリがどれだけsemantic versioningを遵守しているかについては、リリースノートを見たり開発の活発さとmajor versionを比較していくことで知見をためていくしかありません。

まとめ

@apollo/clientのv3.7.17からv3.8.1へのアップデートにより仕様変更が生じました。マイナーであってもライブラリアップデートでは動作変更が起きることがあり、semantic versioningを信頼しきるのは厳しいことが伺え、QAや自動テストの構築が重要になってくると思います。

カケハシではエンジニア募集中です!興味がありましたら是非下記をご覧ください!