iPhoneのホーム画面に追加したPWAで「更新」ボタンが反応しない原因と対処法

iPhoneのPWAで『更新する』が反応しないときの対処法
この記事は約10分で読めます。
記事内に広告が含まれています。

PWA(ホーム画面に追加できるWebアプリ)では、新しい版を公開したときに「新しいバージョンがあります」と知らせて、利用者に「更新する」ボタンを押してもらう作りがよくあります。

私が個人開発したWebアプリ「やめた理由」もこの形です。ところが、iPhone のホーム画面に追加したアプリでだけ、「更新する」を押しても何も起きない不具合がありました。この記事では、そのときに調べたことと、実際に直った対処法をまとめます。

起きたこと

  • iPhone のホーム画面に追加したアプリで、「新しいバージョンがあります」のお知らせは表示される
  • しかし「更新する」を押しても反応がなく、新しい版に切り替わらない
  • PC の確認用ブラウザ(Chromium)では、同じコードで正常に更新できた

使っていた構成は次のとおりです。

項目内容
フレームワークReact、Vite
PWAvite-plugin-pwa(Workbox)。registerType: 'prompt'
更新の通知useRegisterSW の needRefresh でお知らせを出し、ボタンで updateServiceWorker() を呼ぶ

registerType: 'prompt' は、新しい版を自動では適用せず、利用者の操作で切り替える設定です。入力中の内容が勝手な再読み込みで消えないように、この設定にしていました。

PWA が新しい版に切り替わる仕組み

原因の前に、「更新する」を押したときに裏で起きることを確認します。

  1. 新しい版を公開すると、ブラウザが新しい Service Worker をダウンロードする
  2. 古い Service Worker がまだページを担当しているので、新しい方は待機中(waiting)になる。ここで「新しいバージョンがあります」を表示する
  3. 「更新する」を押すと、待機中の Service Worker に SKIP_WAITING(待たずに切り替わって)というメッセージを送る
  4. 新しい Service Worker がページの担当になると、ページ側で controllerchange イベントが起きる
  5. それを合図にページを再読み込みして、新しい版が表示される

vite-plugin-pwa の updateServiceWorker() は、この3〜5をまとめてやってくれる関数です。内部では workbox-window というライブラリが、ページを開いたときから Service Worker の状態を追いかけています。

原因(推定)

先にお断りしておくと、原因は確定できていません。 iPhone のアプリの中で何が起きているかを、開発者ツールで直接は確かめていないためです。

PC の確認用ブラウザでは、「アプリを開いた時点で、すでに新しい版が待機している」状態も再現しましたが、正常に更新できました。そのため、iPhone(WebKit)のホーム画面のアプリ特有の問題だと考えました。

考えられるのは次の状況です。

  • ホーム画面のアプリは、ホームに戻っても終了されず、バックグラウンドで止まったまま残ることが多い
  • その間に新しい版が届くと、workbox-window が把握している「待機中の Service Worker」と、実際の状態がずれる
  • ずれたまま「更新する」を押すと、SKIP_WAITING が待機中の Service Worker に届かない。または切り替わっても、再読み込みの合図を受け取れない

どちらの場合も、画面からは「押しても何も起きない」ように見えます。そこで、ライブラリが把握している状態に頼らず、押した瞬間の状態を取り直して自分で切り替える方針で対処しました。

対処法:待機中の Service Worker に直接指示する

対処のポイントは4つです。

対処内容
切り替えの指示を確実に届ける押した時点で getRegistration() で登録情報を取り直し、waiting に直接 SKIP_WAITING を送る。ライブラリの updateServiceWorker() もあわせて呼ぶ
再読み込みを確実に行うcontrollerchange を自分で監視して再読み込みする
取りこぼしに備える数秒後に状態を確かめ、待機中の版がなくなっていれば切り替わったとみなして再読み込みする
それでも切り替わらなければ案内する「アプリを一度閉じてから開き直してください」と表示する

まず、切り替えを行う関数です。結果として「再読み込みした(reloading)」か「開き直しが必要(needs-restart)」を返します。

// serviceWorkerUpdate.ts
export type ApplyUpdateResult = 'reloading' | 'needs-restart'

export function applyServiceWorkerUpdate(
  updateServiceWorker: () => Promise<void>,
  timeoutMs = 4000,
): Promise<ApplyUpdateResult> {
  const container = navigator.serviceWorker

  return new Promise((resolve) => {
    let settled = false
    let timer: ReturnType<typeof setTimeout> | undefined

    const settle = (result: ApplyUpdateResult) => {
      if (settled) return // 再読み込みは1回だけ
      settled = true
      clearTimeout(timer)
      container.removeEventListener('controllerchange', onControllerChange)
      if (result === 'reloading') window.location.reload()
      resolve(result)
    }
    const onControllerChange = () => settle('reloading')
    container.addEventListener('controllerchange', onControllerChange)

    void (async () => {
      // 1. 登録情報を取り直し、待機中の Service Worker に直接指示する
      try {
        const registration = await container.getRegistration()
        registration?.waiting?.postMessage({ type: 'SKIP_WAITING' })
      } catch {
        // 取り直せなくても、ライブラリ側の指示で切り替わる可能性がある
      }
      // 2. ライブラリ側の指示も送る
      updateServiceWorker().catch(() => {})

      // 3. 一定時間たったら、切り替わったかを確かめる
      timer = setTimeout(async () => {
        const latest = await container.getRegistration().catch(() => undefined)
        settle(latest?.waiting ? 'needs-restart' : 'reloading')
      }, timeoutMs)
    })()
  })
}

次に、お知らせの部品から呼び出します。押したことが分かるように、ボタンを「更新しています…」に変えて押せなくします。

// UpdateNotice.tsx(抜粋)
import { useState } from 'react'
import { useRegisterSW } from 'virtual:pwa-register/react'
import { applyServiceWorkerUpdate } from './serviceWorkerUpdate'

export function UpdateNotice() {
  const {
    needRefresh: [needRefresh],
    updateServiceWorker,
  } = useRegisterSW()
  const [state, setState] = useState<'idle' | 'updating' | 'needs-restart'>('idle')

  if (!needRefresh) return null

  async function update() {
    setState('updating')
    const result = await applyServiceWorkerUpdate(() => updateServiceWorker())
    if (result === 'needs-restart') setState('needs-restart')
  }

  if (state === 'needs-restart') {
    return <p>更新を完了するには、アプリを一度閉じてから開き直してください。</p>
  }
  return (
    <div role="status">
      <p>新しいバージョンがあります。</p>
      <button type="button" disabled={state === 'updating'} onClick={() => void update()}>
        {state === 'updating' ? '更新しています…' : '更新する'}
      </button>
    </div>
  )
}

補足です。

  • SKIP_WAITING を受け取る処理は、vite-plugin-pwa が自動で作る Service Worker(generateSW、registerType: 'prompt')に最初から入っています。Service Worker を自分で書く場合(injectManifest)は、message イベントで self.skipWaiting() を呼ぶ処理を自分で入れてください。
  • 待ち時間の4秒は、普段の切り替えに十分な長さとして決めた値です。
  • 開き直しの案内には、「ホーム画面に追加したアプリは、アプリの切り替え画面で上にスワイプすると閉じられます」のように閉じ方も書いておくと親切です。ホームに戻るだけではアプリは終了しないためです。

すでに古い版が入っている iPhone はどうするか

修正を公開しても、iPhone のアプリで動いているのはまだ修正前のコードです。修正版を届けるための「更新する」が、そもそも反応しません。

この場合は、アプリを完全に閉じて開き直してもらうしかありません。

  1. 画面の下端から上にスワイプして止め、アプリの切り替え画面を出す
  2. アプリを上にスワイプして閉じる
  3. ホーム画面から開き直す

開いているページがなくなると、待機中だった新しい Service Worker が担当になるので、開き直したときには修正版が動きます。私の iPhone でも、この方法で修正版に切り替わりました。

アプリを使っている人がいる場合は、使い方のページなどでこの手順を案内しておくと安心です。

結果

確認したこと結果
修正前の版から、アプリを閉じて開き直す修正版に切り替わった
修正版で、次の更新の「更新する」を押すそのまま新しい版に切り替わった

修正の効果を iPhone で確かめるには、修正版を届けたあと、もう1回アプリを更新して公開する必要があります。修正したのは「更新する」を押す側のコードなので、そのコードが動いている状態で、次の版を受け取らないと試せないためです。私は、次の機能追加の公開に合わせて確かめました。

まとめ

  • iPhone のホーム画面のアプリでは、vite-plugin-pwa の updateServiceWorker() だけでは更新できないことがあった
  • 押した瞬間に getRegistration() で状態を取り直し、waiting に直接 SKIP_WAITING を送る
  • controllerchange を自分で監視し、取りこぼしたときは数秒後に状態を確かめる
  • それでも切り替わらなければ、アプリの開き直しを案内する
  • すでに古い版が入っている端末は、アプリを完全に閉じて開き直せば修正版になる

PWA は便利ですが、iPhone ではブラウザと違う動きをすることがあります。公開したら、実際の iPhone のホーム画面のアプリで更新まで試しておくと安心です。

この修正を入れた「やめた理由」については、紹介記事と開発記もあわせてご覧ください。

コメント

タイトルとURLをコピーしました