create-react-appで作ったcache-firstなPWAでアップデートを検出して更新する


はじめに

まとめていたら長くなってしまいました。

実装だけ知りたい方は実装セクションからどうぞ。

GitHubで本稿で紹介したコードの全体を公開しています。

create-react-appのService Worker

create-react-app (CRA) を使うとService Worker(SW)を簡単に使用にしてPWA化することができます。

CRAでは、workbox-webpack-pluginがデフォルトで組み込まれており、Workboxがcache-firstというキャシュ戦略で動作するように設定されています。

CRAのWorkboxの設定は、ejectするかreact-app-rewiredを使って上書きしない限り、デフォルトのものに限定されます。ejectするならCRAは使いませんし、react-app-rewiredreact-scripts 2.x互換で古いため使いたくありません。

cache-firstでは優先してSW内のキャッシュを読みに行くため、ホスティングサーバのコンテンツが更新されたときに、ページをリロードするだけでは即座に最新のコンテンツを読み込むことはできません。

私は開発している最中にデプロイしたものを使って動作確認していたのですが、standaloneでインストールしたPWAが更新されなくて困りました。

本稿ではSWの更新時の挙動を簡単に確認した後に、(インストール済みの)PWAを最新の状態に更新するアップデートダイアログを実装していきます。

ServiceWorker更新時の挙動

Service Worker の紹介から引用します。

  1. Service Worker の JavaScript ファイルを更新します。 ユーザーがサイトに移動してきたとき、ブラウザは Service Worker を定義するスクリプト ファイルをバックグラウンドで再度ダウンロードしようとします。 現在ブラウザが保持しているファイルとダウンロードしようとするファイルにバイトの差異がある場合、それは「新しい」と認識されます。
  2. 新しい Service Worker がスタートし、install イベントが起こります。この時点では、まだ古い Service Worker が現在のページを制御しているため、新しい Service Worker は waiting 状態になります。
  3. 開かれているページが閉じると、古い Service Worker は終了し、新しい Service Worker がページを制御するようになります。
  4. 新しい Service Worker がページを制御するようになると、activate イベントが起こります。

上記を要約すると、firebase deploy などで新しいコードをデプロイした後にページをリロードすると新しいSWがwaiting状態となって、すぐには更新はされません。一度すべてのタブで該当ページを閉じて再度開くことによって新SWが有効になり、ページが更新されます。(この挙動は検証Application > Service Workersから確認できます。)

インストール済みのPWAに関しては、一度アンインストールして再度インストールしなければなりません。
追記:この記述についてはservice-worker.jsのキャッシュ設定をホスティング側で無効化することでインストール済みであっても更新できるようです。(qrusadorzさんのコメントより)

一般のユーザにPWAを再インストールさせるのは現実的ではないですし、開発者にとっても非常に面倒くさいです。

この問題を解決するために、skipWaiting イベントを実行して強制的に新SWを有効にすることでPWAを更新します。

アプリ内でwaiting状態のSWを検出して更新ダイアログを表示し、ボタンを押すとskipWaitingの実行とページのリロードをするプログラムを書いてきましょう。

実装

環境

  • "react-scripts" (create-react-app): "3.3.0"
  • "typescript": "3.7.3"
  • node: v12.13.0
  • デプロイ: Firebase hosting

1. waiting状態のService Workerを検出してonUpdateを発火する

src/serviceWorker.ts
function registerValidSW(swUrl: string, config?: Config) {
  navigator.serviceWorker
    .register(swUrl)
    .then(registration => {
      // 以下3行を追加
      if (registration.waiting && config && config.onUpdate) {
        config.onUpdate(registration);
      }
      registration.onupdatefound = () => {
        const installingWorker = registration.installing;
        // 省略
    }
    // 省略
}

この変更によって、アップデートボタンを押される前にページをリロードされた場合でも、アップデートボタンを押すか新しいSWがactivateされるまで、更新ダイアログを表示し続けることができます。

2. 更新ダイアログのComponentを作る

src/SWUpdateDialog.tsx
import React, { useState } from 'react';

export const SWUpdateDialog: React.FC<{ registration: ServiceWorkerRegistration }> = ({ registration }) => {
  const [show, setShow] = useState(!!registration.waiting);
  const style: React.CSSProperties = {
    width: '100%',
    backgroundColor: 'green',
  };
  const handleUpdate = () => {
    registration.waiting?.postMessage({ type: 'SKIP_WAITING' });
    setShow(false);
    window.location.reload();
  };

  return show ? (
    <div style={style}>
      <span>新しいバージョンがリリースされました。🎉</span>
      <button onClick={handleUpdate}>アップデート</button>
    </div>
  ) : (
    <></>
  );
};

ポイントは、registration.waiting?.postMessage({ type: 'SKIP_WAITING' });skipWaitingイベントを実行する点です。
これは、react-scripts buildで生成されるservice-worker.jsで下記のコードが生成されるためです。

build/service-worker.js
self.addEventListener('message', (event) => {
  if (event.data && event.data.type === 'SKIP_WAITING') {
    self.skipWaiting();
  }
});

また、skipWaitingを実行した後はページをリロードする必要があります。

3. onUpdate実行時に更新ダイアログを表示する

ダイアログのコンテナを用意します。

src/App.tsx
const App: React.FC = () => {
  return (
    <div className="App">
      <div className="SW-update-dialog"></div>
      <header className="App-header">
       {/* 省略 */}
      </header>
    </div>
  );
};

次にserviceWorkerregisterの引数でonUpdateをoverrideします。
waiting状態のSWがある場合に、<SWUpdateDialog />をrenderします。

src/index.tsx
serviceWorker.register({
  onUpdate: registration => {
    if (registration.waiting) {
      ReactDOM.render(<SWUpdateDialog registration={registration} />, document.querySelector('.SW-update-dialog'));
    }
  },
});

動作確認

ここまでできたら一度Firebaseなどにデプロイしてページを開いてください。

次にApp-headerでもどこでも良いので変更を加えた後に、再度buildしたものをデプロイしてページを更新してください。

下記のようなダイアログが出て、クリックしてページが更新できたら成功です。🙌

余談

CRAでWorkboxの設定をカスタマイズできるようになるかも・・・?

こちらのプロポーザルworkbox.config.jsを読み込んでWorkboxの設定をカスタマイズできるようにする提案がされています。
マイルストーンが3.4になっているので、次のリリースで実装される可能性があります!!(2020/01/05 現在は v3.3

アップデートダイアログのUX

アップデート時の挙動として以下のアプローチが考えられます。

  • 今回のサンプルのような任意でクリック可能なバナー
  • 画面全体に表示されて選択を強制させるモーダル
  • ユーザに通知を与えずにアップデート

親切なのは1つ目です。2つ目は賛否が分かれそうですね。3つ目はいきなりページが更新されてユーザに不安を与えかねないので無しかなと思います。

ユーザが任意でアップデートをキャンセルできる場合、任意でアップデートできるような導線をはらないといけないので手間が増えそうですね。

僕が現在開発しているアプリでは、常に最新の状態にさせたいので2つ目のアプローチをとっています。

おわりに

最後まで読んで頂き、ありがとうございました。
内容に誤りがあったり、もっと良い方法を知っている方はコメントで教えて頂けると幸いです。

参考文献

  1. https://azdanov.js.org/blog/create-react-app-service-worker
  2. https://developers.google.com/web/tools/workbox/modules/workbox-strategies
  3. https://developers.google.com/web/fundamentals/primers/service-workers