「なんで昼休みのあと戻ると白画面になるの?」

Cursor で綺麗な Expo app を作った。Claude Code が auth flow、dashboard、settings screen を生成してくれた。出荷した。ユーザーも入った。すると DM が飛んでくる。

  • 「昼休みのあと開くと白画面になるのはなんで?」
  • 「なんで毎朝ログインし直しになるの?」
  • 「Instagram から戻ったらデータが消えたんだけど?」

壊れた app を作ったわけではありません。ネイティブ app の箱の中に web app の発想を持ち込んだだけです。 Browser には app lifecycle がない。mobile app にはある。ユーザーがホームボタンを押しても app は死なず、眠る。6 時間後にアイコンをタップすると、また起きる。そして 6 時間前に正しかった前提は、もう全部あやしくなっています。

resume 時の問題

ユーザーが数時間ぶりに戻ってきたとき、判断しないといけないことがあります。session はまだ有効か。data は十分に新しいか。多くの Vibe-coded app はこれを screen ごとに場当たりで処理します。ある screen は再 fetch する。別の screen は stale data を出し続ける。3 つ目の screen は session が残っている前提で落ちる。

解決策は、最後に active だった時刻からの経過時間で resumeStatus を導出し、app 全体がそれに反応するようにすることです。

// src/context/app-lifecycle-provider.tsx

import { AppState } from 'react-native'

export type AppResumeStatus =
  'fresh' | 'resumed' | 'stale' | 'expired'

export function getResumeStatus(
  lastActiveAt: number,
  now: number,
  staleMs: number = 5 * 60 * 1000,       // 5 minutes
  expireMs: number = 24 * 60 * 60 * 1000  // 24 hours
): AppResumeStatus {
  const idleMs = now - lastActiveAt
  if (idleMs >= expireMs) return 'expired'
  if (idleMs >= staleMs) return 'stale'
  return lastActiveAt === 0 ? 'fresh' : 'resumed'
}

4 つの state があり、それぞれ意味がはっきりしています。

  • fresh: 初回起動。前回の session がない。
  • resumed: 5 分以内に復帰。data はおそらく問題ない。
  • stale: 5 分以上離れていた。重要な data を再 fetch するタイミング。
  • expired: 24 時間以上離れていた。fresh launch に近い扱いをする。

Lifecycle Provider

AppState への subscribe は 1 回だけにする。そして導出した status をどこからでも読めるようにする。

export function AppLifecycleProvider({
  children,
}: PropsWithChildren) {
  const { storage } = useServices()
  const [state, setState] = useState({
    appState: AppState.currentState,
    lastActiveAt: 0,
    resumeStatus: 'fresh' as AppResumeStatus,
    ready: false,
  })

  useEffect(() => {
    const sub = AppState.addEventListener(
      'change',
      (nextAppState) => {
        if (nextAppState === 'active') {
          const resumeStatus = getResumeStatus(
            state.lastActiveAt,
            Date.now()
          )
          setState((s) => ({
            ...s,
            appState: nextAppState,
            resumeStatus,
          }))
        } else if (nextAppState === 'background') {
          storage.fastStorage.set('lastActiveAt', Date.now())
          setState((s) => ({
            ...s,
            appState: nextAppState,
            lastActiveAt: Date.now(),
          }))
        }
      }
    )

    storage.fastStorage
      .get<number>('lastActiveAt')
      .then((ts) => {
        setState((s) => ({
          ...s,
          lastActiveAt: ts ?? 0,
          resumeStatus: getResumeStatus(ts ?? 0, Date.now()),
          ready: true,
        }))
      })

    return sub.remove
  }, [storage])

  return (
    <LifecycleContext.Provider value={state}>
      {children}
    </LifecycleContext.Provider>
  )
}

ポイントはここです。auth、updates、data refresh のように resume を気にする provider は、全部この context の resumeStatus を読む。screen ごとに AppState.addEventListener を書かない。feature ごとに background 時間を推測しない。

route guard は両方を待つ

route guard は auth の復元lifecycle の ready の両方が揃うまで描画を待つべきです。

// app/_layout.tsx

function RootNavigator() {
  const { session, loading } = useAuth()
  const { ready: lifecycleReady } = useAppLifecycle()

  if (loading || !lifecycleReady) return null

  return (
    <Stack>
      <Stack.Protected guard={!session}>
        <Stack.Screen name='login' />
      </Stack.Protected>
      <Stack.Protected guard={!!session}>
        <Stack.Screen name='(app)' />
      </Stack.Protected>
    </Stack>
  )
}

web 開発だけをやってきた人がこれを考えなくて済むのは、browser には app lifecycle がないからです。mobile 開発ではそうはいきません。Autotomy Expo Starter Pack には lifecycle 管理が最初から入っているので、Vibe-coded app が「ユーザーが戻ってきた瞬間」に壊れるのを防げます。