ローディング状態の表示を VRT で手軽に担保する
こんにちは、mashabow です。昨日から始まった Social PLUS Tech Blog の技術記事 1 本目なのでビビっています 🤗
UI のローディング状態
さて、ローディング状態ってありますよね。以下はソーシャル PLUS の画面の一例ですが、API からのデータ取得中に表示する、こういうやつです。
最近はスケルトンスクリーンを表示することも当たり前になっています。細かな気遣いですが、ローディング状態は快適な UX のためには欠かせません。
しかしこのローディング状態、普段コードを書いている最中は、ローカルの API モックを使ったり高速なネットワーク環境があったりするので、ともすればうっかり見過ごしがちな部分です。ローディング状態の表示が壊れていたときに、はたして気づけるでしょうか? ブラウザの DevTools でネットワークのスロットリングを有効にすれば確認はできますが、いちいち気にするのは面倒です。そして、スロットリングを解除し忘れて自分にイラッとするのもあるあるです(ですよね?)。
ということで、この記事では 「ローディング状態の表示が壊れていないこと」をできるだけ手軽に確認・担保する 仕組みをチュートリアル形式で紹介します。なお、「ローディング状態の表示」だとちょっと長いので、以下では単に「ローディング表示」と呼ぶことにします。
Storybook でローディング表示を確認できるようにする
まずは Storybook でローディング表示を確認できるとうれしいですよね? 「このコンポーネントってどんなローディング表示だったっけ?」と思ったときに、ローディング表示の story があれば、エンジニアもデザイナーも手軽に確認することができます。
では、どう書けばいいでしょうか? もちろん、コンポーネントに isLoading
のような prop があれば簡単です。
export const Loading: Story = {
args: {
isLoading: true,
},
};
が、すべてのコンポーネントがそんな都合のいい設計になっているわけでもありません。コンポーネントの中でリクエストを飛ばす場合も普通にあるでしょう。ページコンポーネントのような、粒度の大きいコンポーネントであればなおさらです。
そこで、MSW を使って「レスポンス待ち」の状態を再現することを考えます。MSW では、ctx.delay('infinite')
(v1)や delay('infinite')
(v2)を使うと「いつまでたってもレスポンスが返ってこないリクエストハンドラ」を作ることができます。
import { rest } from "msw";
rest.get("/foo", (_req, res, ctx) => res(ctx.delay("infinite")));
まず、MSW と msw-storybook-addon
を導入します。導入手順についてはこの記事では割愛しますので、公式のドキュメントを参照してください。次に、いつまで経ってもレスポンスが返ってこないリクエストハンドラを作り、それを使った Loading
story を用意します。
import { rest } from "msw";
export const Loading: Story = {
parameters: {
msw: [
rest.get(
"https://example.com/foo", // Foo コンポーネント内で叩いているエンドポイント
(_req, res, ctx) => res(ctx.delay("infinite")),
),
],
},
};
これでローディング表示の story ができました 🎉
もう少し楽にリクエストハンドラを書く
でも、リクエスト先のエンドポイントの数だけリクエストハンドラを書くのは面倒ですよね? また、例えばページコンポーネントの Loading
story を書こうとした場合、「子や孫のコンポーネントがどこにリクエストしているか」なんて気にしたくありません。
というわけで、楽する方法を考えます。簡単のために「Loading
story では、どこに対するリクエストであってもレスポンスが返ってこない」という前提を置くことにしましょう。リクエストハンドラの URL 指定には正規表現が使えるので、雑に /.*/
を指定します。HTTP メソッドも GET
が多いとは思いますが、この際なのですべてのメソッドを対象にする rest.all()
を使いましょう。これで、どんなリクエストに対しても永遠にレスポンスを返さないリクエストハンドラである infiniteRequestHandler
ができました。
import { rest } from "msw";
export const infiniteRequestHandler = rest.all(/.*/, (_req, res, ctx) =>
res(ctx.delay("infinite"))
);
あとは Loading
story に指定するだけです。
import { infiniteRequestHandler } from "./handlers"
export const Loading: Story = {
parameters: {
msw: [infiniteRequestHandler],
},
};
上のように infiniteRequestHandler
を別ファイルに切り出しておけば、いろいろな *.stories.tsx
ファイルからインポートして使い回すことができます。やっていることは単純ですが、だいぶ楽になりました 👍
状況によっては「このリクエストは infiniteRequestHandler
から除外したい」ようなケースがあるかもしれませんが、そのような場合は個別に上書きしてやれば OK です。
export const PartiallyLoading: Story = {
parameters: {
msw: [
infiniteRequestHandler,
// 個別に上書き
rest.get(
"https://example.com/foo",
(_req, res, ctx) => res(ctx.json({ message: "Hi!" })),
),
],
},
};
ローディング表示が壊れていないことを VRT で担保する
ローディング表示の story を用意しましたが、これだけでは「開発の途中でいつのまにかローディング表示が壊れていた」ということが起こりえます。そこで、「ローディング表示が壊れていないこと」を担保する方法について考えていきます。これについてもできるだけ手軽にやりたいので、ビジュアルリグレッションテスト(VRT)を活用することにしましょう。
定番の構成ですが、先ほど用意した Loading
story のスクリーンショットを Storycap で撮影し、reg-suit を使って前のコミットのスクリーンショットと比較することにします。
しかし、そのまま Storycap を実行するとスクリーンショットの撮影が終わらず、タイムアウトしてしまいます。というのも、Storycap のデフォルトでは「リクエストがすべて完了するまで待ってから、スクリーンショットを撮影する」という制御が入っているからです。この待ち処理はスクリーンショットを安定させるためのものですが、Fetch API によるリクエストも対象になっています。そのため、infiniteRequestHandler
を使っている story では、いつまで経ってもスクリーンショットの撮影が行われません。
このタイムアウトを回避するためには、waitAssets
オプションと waitImages
オプションを両方とも false
にして、リクエスト待ちを無効にする必要があります。
import { infiniteRequestHandler } from "./handlers"
export const Loading: Story = {
parameters: {
msw: [infiniteRequestHandler],
// Storycap の設定
screenshot: {
waitAssets: false,
waitImages: false,
},
},
};
これでタイムアウトせずに、無事スクリーンショットを撮影できるようになりました!
Storycap の待ち処理の詳細
この待ち処理は、ResourceWatcher
クラスの以下の箇所で行われています。各リクエストに対して resolve されたかどうかをチェックしているようです。
fetch
などによるリクエスト(request.resourceType() === 'xmlhttprequest'
)はこのチェック対象になっています。
waitAssets
と waitImages
の両方を false
にすると、以下の if
文によって ResourceWatcher
による待ち処理がスキップされます。
waitAssets
と waitImages
オプションを自動で設定する
さて、最後にもうちょっと楽をしましょう。infiniteRequestHandler
を使っている story に毎回毎回 waitAssets
と waitImages
を指定する必要があるのは、ぶっちゃけ面倒ですよね? わたしは面倒です。指定をたびたび忘れましたし、他のメンバーが書いた Loading
story をレビューするときにも見落とします。ということで、自動で設定してくれるようにしましょう。
全 story に対して、「infiniteRequestHandler
を使っていたら waitAssets
と waitImages
を false
にする」が自動でできれば OK なわけです。グローバルなデコレータを書きましょう。
import type { Preview } from "@storybook/react";
import { infiniteRequestHandler } from './handlers';
const preview: Preview = {
decorators: [
(storyFn, context) => {
if (context.parameters.msw.includes(infiniteRequestHandler)) {
context.parameters.screenshot.waitAssets = false;
context.parameters.screenshot.waitImages = false;
}
return storyFn(context);
},
...,
],
...,
};
export default preview;
こうすれば、waitAssets
と waitImages
の指定が不要になります 🎉
import { infiniteRequestHandler } from "./handlers"
export const Loading: Story = {
parameters: {
msw: [infiniteRequestHandler],
// いちいち指定しなくてもよくなった
// screenshot: {
// waitAssets: false,
// waitImages: false,
// },
},
};
まとめ
というわけで、最終的には以下の数行だけで Loading
story が書けるようになりました。めんどくさがりやなわたしでも、これぐらいだったらいろいろなコンポーネントに Loading
story をビシバシ書いていけます。
import { infiniteRequestHandler } from "./handlers"
export const Loading: Story = {
parameters: {
msw: [infiniteRequestHandler],
},
};
実際の業務でもこんな感じに Loading
story を用意して、VRT を行っています。
もっと便利な方法があるよ!という方がいましたら、ぜひコメントで教えてください。ではでは。
Discussion