はじめに
AI在庫管理のフロントエンド開発をしている木本です。
先日、@apollo/client
のv3.7.17からv3.8.1へのアップデートに伴う大規模なデグレが発生しました。具体的には無限スクロールで情報を取ってくる画面で、一切情報が見られなくなってしまう状態となりました。一次対応として@apollo/client
をv3.7.17に戻すことでデグレは解消されました。
この@apollo/client
アップデートについて、情報があまりネット上に広まっていないようなのでブログ記事として情報を残したいと思います。
TLDR
@apollo/client
をv3.7をv3.8以降にアップデートしてfetchMore
周りに問題が発生したときは、useQuery
にnotifyOnNetworkStatusChange: 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); } }, });
こちらのコードは、onCompleted
でfetchMore
を動かすことで、ページングされている情報の取得を一気に行うことを意図しています。pageInfo.hasNextPage
がtrue
ならば、pageInfo.nextCursor
でfetchMore
が動くという意図です。
こちらのコードはv3.7.17までは動いていましたが、v3.8.0以降は動きません。
こちらが例です(Special Thanks: 江藤さん)。
取得中はローディング画面としておき、最終的にhasNextPage
がfalse
になったのを検知してloading
をfalse
に戻すのですが、このloading
はいつまで経ってもfalse
にならないため、ずっとローディング画面のままとなる障害が発生してしまいました。
なぜこのコードは動かなくなったのか
onCompleted
がfetchMore
が動かなくなるという問題はこちらの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以前)は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
andv3.5.0
and greater: writes to the cache (it doesn't matter how the cache update occurs: directly viacache.writeQuery
, via a separateuseMutation
call, etc.) trigger theonCompleted
callback passed touseQuery
, whereas inv3.4.x
,onCompleted
was triggered when the initial network request completed, and not on subsequent cache writes.
具体的にはcache.writeQuery
やuseMutation
が上げられており、fetchMore
でもcache.writeQuery
を内部で使用していました。たしかにこれが原因でクエリのonCompleted
がトリガーされてしまう事態はバグと言えます。そのため、キャッシュが直接変更されたことを検知するためにnetworkStatus
の監視を行う形に修正されたようです。ただ、その修正によりfetchMore
の実行でもonCompleted
が実行されなくなってしまう問題が生じているという状態とのことです。
解決法
開発者はPRを提出した段階でこの問題を把握していたようで、解決法としてuseQuery
にnotifyOnNetworkStatusChange: 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
の状態の変動を確認できます。
さきほどのパッチでは、こちらのnetworkStatus
がonCompleted
のトリガー判定に利用されています。ドキュメントの記載からはnotifyOnNetworkStatusChange: true
を指定しないとnetworkStatus
はundefined
のまま固まるように読めますが、実際はそんなことはなく、false
時であっても内部的にいろいろ変動しており、その変動の仕方がnotifyOnNetworkStatusChange
のbool値によって変わってきます。
notifyOnNetworkStatusChange
がfalse
時には、fetchMore
が実行されてもnetworkStatus
はready
のまま固定されます。しかしnotifyOnNetworkStatusChange
がtrue
のときはnetworkStatus
はfetchMore
という値に切り替わります。
アップデート後、networkStatus
の値の変動が発生しなかった場合onCompleted
がトリガーされることがなくなりました。そのため、notifyOnNetworkStatusChange
をtrue
に入れる必要が出てきてしまったという形です。
|
AI在庫管理では、最終的にこのnotifyOnNetworkStatusChange
を修正することで@apollo/client
を無事にアップデートしています。
semantic versioning 上正しいのか?
major versionを更新せずにBreaking Changeを入れることはsemantic versioningを破っているように見えます。@apollo/client
のCHANGELOGを見てみると、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や自動テストの構築が重要になってくると思います。
カケハシではエンジニア募集中です!興味がありましたら是非下記をご覧ください!