Rork で組んだ試作アプリに Supabase のログインを繋いだ晩、手元の iPhone で起動を何度も繰り返しておりました。ログインは済んでいるはずなのに、起動のたびにログイン画面がほんの一瞬だけ見えて、そのあとホームに切り替わるのです。
最初は気のせいだと思いました。ところが画面を録画して 1 コマずつ送ると、確かにログイン画面が描かれてから消えていました。ログアウトを押しても画面が変わらず、アプリを閉じて開き直すとようやくログイン画面になる、という別の症状も一緒に出ていたのです。
同じ症状は expo/expo の Issue #37305 に集まっています。2025 年 6 月に立ったまま開いていて、コメントは 26 件——黒い画面のまま止まる方、ログアウト後に画面が更新されない方、ドキュメントどおりに写したのに動かないと書く方が並んでおります。
最初にお伝えしたいのは、Protected routes そのものは壊れていない、という一点です。壊れていたのは私の側で、ログインしているかどうかを「はい」と「いいえ」の二つだけで持っていたことが原因でした。そう気づいたのは、録画を 1 コマずつ送り終えたあとでした。現実には三つ目の「まだ分からない」があり、起動直後はほぼ必ずそこにいるのです。
二値の guard に、三値の現実を押し込んでいました
Stack.Protected の guard は真偽値を受け取ります。ログイン済みなら true、そうでなければ false——それ自体は正しい設計です。
問題は、起動直後の「セッションをまだ読めていない」状態を、私が false に丸めていたことでした。session が null のあいだは未ログイン扱いになり、ログイン画面が描かれます。数百ミリ秒後にセッションが読み終わると guard が反転し、ホームへ切り替わるのです。
| 実際の状態 | 二値のときの guard | 描かれる画面 | 三値にしたときの扱い |
| まだ読めていない | false(null を丸める) | ログイン画面が一瞬 | どちらも描かない |
| 未ログイン | false | ログイン画面 | ログイン画面 |
| ログイン済み | true | ホーム | ホーム |
壁紙アプリで、広告非表示を購入済みの検証端末に起動直後だけ広告が一度出る件を直したときと、まったく同じ構図でした。購入状態を読み終える前に「未購入」として扱っていたのが原因で、「まだ分からない」を作った瞬間に消えたのです。個人開発で何度も踏んできた穴なのに、認証でもう一度踏みました。Issue の長さを見るかぎり、私だけの癖ではないのかもしれません。
判定できないあいだは、どちらの画面も出しません。 今回の修正は、この一文に尽きます。
用意するもの
| 部品 | 条件 | 備考 |
| Rork で生成した Expo プロジェクト | expo-router 5 以降(SDK 53 以降) | Protected routes は SDK 53 で入りました |
| @supabase/supabase-js | 2 系 | Rork の「Connect Supabase」で入る版で十分です |
| expo-splash-screen | 同梱 | 「不明」のあいだスプラッシュを保つために使います |
| 実機か Expo Go | iOS・Android どちらか 1 台 | 録画できる端末だと確認が楽です |
Rork の「Connect Supabase」で繋いだプロジェクトには、lib/supabase.ts のようなクライアントが既にあります。ストレージアダプタも含めてそのまま使い、認証状態の持ち方だけを差し替えます。
Step 1: 認証状態を三つに分けた AuthProvider を書きます
まず、アプリ全体で参照する認証状態を unknown / signed-out / signed-in の三つに固定します。session オブジェクトそのものを外に配らず、状態とセッションを別々に持つのが要点です。
// providers/auth.tsx
import { createContext, useContext, useEffect, useState } from 'react';
import type { Session } from '@supabase/supabase-js';
import { supabase } from '@/lib/supabase';
export type AuthStatus = 'unknown' | 'signed-out' | 'signed-in';
type AuthValue = {
status: AuthStatus;
session: Session | null;
};
const AuthContext = createContext<AuthValue>({ status: 'unknown', session: null });
// 「不明」に留まってよい上限。私は 3 秒に置いています
const RESOLVE_TIMEOUT_MS = 3000;
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [value, setValue] = useState<AuthValue>({ status: 'unknown', session: null });
useEffect(() => {
let cancelled = false;
const apply = (session: Session | null) => {
if (cancelled) return;
setValue({ status: session ? 'signed-in' : 'signed-out', session });
};
// 1) 保存済みセッションを読む。必要なら更新まで行う
const initial = supabase.auth.getSession().then(({ data, error }) => {
if (error) throw error;
return data.session;
});
// 2) 時間切れ・例外はどちらも「未ログイン」に倒す(判定不能のまま保護画面を開けない)
const timeout = new Promise<Session | null>((resolve) =>
setTimeout(() => resolve(null), RESOLVE_TIMEOUT_MS)
);
Promise.race([initial, timeout]).then(apply).catch(() => apply(null));
// 3) 以後の変化(ログイン・ログアウト・トークン更新)はここで受ける
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
apply(session);
});
return () => {
cancelled = true;
subscription.unsubscribe();
};
}, []);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
return useContext(AuthContext);
}
肝心なのは、読めた・読めなかった・例外のどの道でも必ず unknown から抜けることです。抜ける道が一つでも欠けると、次の Step で保つスプラッシュが永遠に消えません。
3 秒という上限は私の判断です。機内モードで起動したときにトークン更新が待たされる場面を想定して置きました。時間切れを「未ログイン」に倒すと、オフラインのログイン済みの方を一度ログイン画面へ落とすことになります——それでも、判定できないまま保護画面を開けるよりは、と線を引きました。あとで通信が戻れば onAuthStateChange が拾って、ホームへ切り替わります。
Step 2: ルートの _layout.tsx で、「不明」のあいだは navigator を描きません
Issue のコメントに、起動の前後で条件が変わらなければ動くのに、起動中に変わると黒い画面で止まる、という報告があります。私の手元でも、まさに起動中に guard が反転していました。
ドキュメントには、guard が true から false に変わると、その画面の履歴はすべて取り除かれると書かれています。つまり guard は効いていないのではなく、効きすぎているのです。navigator が組み上がる最中に反転させると、まだ無い履歴を消しに行く形になり、行き場を失った画面が黒くなる——私はそう読んでいます。
解決は単純で、「不明」のあいだは Stack を描かなければよいのです。スプラッシュを手動で保ち、状態が定まってから初めて navigator を出します。
// app/_layout.tsx
import { useEffect } from 'react';
import { Stack } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import { AuthProvider, useAuth } from '@/providers/auth';
// モジュール読み込み時に一度だけ。自動で消えるのを止める
SplashScreen.preventAutoHideAsync().catch(() => {});
export default function RootLayout() {
return (
<AuthProvider>
<RootNavigator />
</AuthProvider>
);
}
function RootNavigator() {
const { status } = useAuth();
useEffect(() => {
// 状態が定まった瞬間にだけスプラッシュを消す
if (status !== 'unknown') {
SplashScreen.hideAsync().catch(() => {});
}
}, [status]);
// 「不明」のあいだは navigator を組み立てない。これが黒画面と一瞬のログイン画面を両方止める
if (status === 'unknown') {
return null;
}
const signedIn = status === 'signed-in';
return (
<Stack screenOptions={{ headerShown: false }}>
{/* guard には必ず boolean を渡す。session オブジェクトを渡さない */}
<Stack.Protected guard={signedIn}>
<Stack.Screen name="(app)" />
</Stack.Protected>
<Stack.Protected guard={!signedIn}>
<Stack.Screen name="(auth)" />
</Stack.Protected>
</Stack>
);
}
guard={session} のようにオブジェクトを渡している例をよく見かけますが、私は boolean に揃えています。null と undefined と空オブジェクトの扱いを毎回考えなくて済みますし、signedIn という名前で読んだときに意図が残るためです。
Lab のサイトでも、会員判定がクライアントで返る前にペイウォールを描いてしまい、会員である自分が一瞬ロックを見る、という同じ形の不具合を直したことがあります。判定が返るまで「どちらの状態のものも描かない」という答えは、Web でもアプリでも変わりませんでした。
Step 3: グループごとに _layout.tsx を置き、画面を一つ残らず登録します
ここが、ドキュメントどおりに写しても動かなかった方の多くがつまずいた箇所です。私も最初、(app) フォルダに _layout.tsx を置いていませんでした。
expo-router の基礎の説明には、レイアウトファイルは常に必要というわけではない、とあります。ところが Issue のコメントには、グループに _layout.tsx を足したら動いた、という報告がいくつも並んでいます。私の環境でも同じで、Stack.Screen name="(app)" が指す先にレイアウトが無いと、グループが画面として登録されないようでした。
app/
├── _layout.tsx ← Step 2 のルート。Protected の分岐はここだけ
├── (auth)/
│ ├── _layout.tsx ← 必ず置く。中身は <Stack /> だけでよい
│ ├── sign-in.tsx
│ └── sign-up.tsx
└── (app)/
├── _layout.tsx ← 必ず置く
├── index.tsx
└── settings.tsx
// app/(app)/_layout.tsx
import { Stack } from 'expo-router';
export default function AppGroupLayout() {
// グループ内の画面は Stack がファイル名から自動で拾う。
// ここでは Protected を重ねない(入れ子は次の節で触れます)
return <Stack screenOptions={{ headerShown: false }} />;
}
// app/(auth)/_layout.tsx
import { Stack } from 'expo-router';
export default function AuthGroupLayout() {
return (
<Stack screenOptions={{ headerShown: false }}>
{/* 名前は先頭にスラッシュを付けない。router.replace('/sign-in') とは書き方が違う */}
<Stack.Screen name="sign-in" />
<Stack.Screen name="sign-up" />
</Stack>
);
}
もう一つ、Issue の終盤に、画面を一つ登録し忘れただけで認証まわりの遷移が全部壊れた、という報告があります。ルートの _layout.tsx に画面名を明示して書くなら、そのレベルにある画面は一つ残らず書く——書き漏れは「その画面だけ動かない」ではなく「全体が黒くなる」形で返ってくるのです。
同じ画面を二つのグループに置くこともできません。ログイン前後で見せたい共通画面があるなら、どちらかのグループに一度だけ置き、もう片方からはリンクで飛ばします。
動作確認は 4 つの起動パターンで行います
修正が効いたかどうかは、録画して 1 コマずつ送るのがいちばん確かでした。目で見て「消えた気がする」は、私の場合あてになりませんでした。
| パターン | 手順 | 期待する見え方 |
| ログイン済みで起動 | ログイン後にアプリを完全に終了して開き直す | スプラッシュからホームへ。ログイン画面は 1 コマも出ない |
| 未ログインで起動 | ログアウト後に終了して開き直す | スプラッシュからログイン画面へ |
| アプリ内でログアウト | 設定画面のログアウトを押す | その場でログイン画面に切り替わる(再起動不要) |
| 機内モードで起動 | ログイン済みのまま機内モードにして開く | 3 秒以内にログイン画面へ。通信を戻すとホームへ切り替わる |
三つ目の「その場で切り替わる」を成り立たせるために、ログアウト処理では画面遷移を自分で書きません。guard が反転すれば expo-router が (app) の履歴を消してくれるので、こちらは signOut を待つだけにします。
// app/(app)/settings.tsx の一部
import { supabase } from '@/lib/supabase';
async function handleSignOut() {
const { error } = await supabase.auth.signOut();
if (error) {
// 通信断などで signOut が失敗したときは、ここで止める。
// router.replace('/sign-in') で無理に飛ばすと、保存済みトークンが残ったまま画面だけ未ログインになる
console.warn('sign-out failed', error.message);
return;
}
// 成功時は何もしない。onAuthStateChange → status が signed-out → guard が反転 → 画面が切り替わる
}
ログアウトの失敗を握りつぶさない理由は、消したはずのトークンが再起動で戻る — SecureStore の削除失敗を検知する設計に書いた件と同じです。画面だけ未ログインにしても、次の起動でトークンが戻ってくるのです。
落とし穴: 入れ子の Protected、redirectTo、そして本当の防衛線
入れ子にしない。 ログイン済みかつオンボーディング未完了、のような二段の判定を Stack.Protected の入れ子で書くと、外側が false なのに内側の画面へ飛ばされた、という報告が Issue にあります。私は入れ子をやめて、平らに並べる形に統一しました。
// 入れ子(避ける)
<Stack.Protected guard={signedIn}>
<Stack.Protected guard={!onboarded}>
<Stack.Screen name="onboarding" />
</Stack.Protected>
<Stack.Screen name="(app)" />
</Stack.Protected>
// 平坦(こちらに統一)
<Stack.Protected guard={signedIn && !onboarded}>
<Stack.Screen name="onboarding" />
</Stack.Protected>
<Stack.Protected guard={signedIn && onboarded}>
<Stack.Screen name="(app)" />
</Stack.Protected>
<Stack.Protected guard={!signedIn}>
<Stack.Screen name="(auth)" />
</Stack.Protected>
onboarded のような追加の判定も、認証と同じく「不明」を持ちます。Step 1 の AuthProvider と同じ形でもう一つ状態を作り、両方が定まるまで navigator を描かないようにすると、オンボーディング画面が一瞬出る同型の症状も一緒に消えます。
redirectTo は SDK 58 から。 拒否されたときの飛び先を指定する redirectTo は、ドキュメント上 SDK 58 以降とされています。本日の時点で SDK 58 はベータのままなので、それより前の版では「anchor か、最初に使える画面へ飛ぶ」という既定の挙動に合わせて画面の並び順を決めます。上のコードで (auth) を最後に置いているのは、未ログイン時に最初に使える画面がそこになるようにするためです。
Protected はクライアント側だけ。 ドキュメントにも、サーバー側の認証やアクセス制御の代わりにはならないと明記されています。画面を隠しても、データを守るのは Supabase の Row Level Security です。キーの置き場所ごと含めて、そのAPIキー、アプリの中に入っていませんか — Rork の環境変数と Supabase Edge Function の使い分けに書いた線引きと同じ考え方で扱っています。
明日、最初に開くファイル
自分のアプリの app/_layout.tsx を開いて、guard に渡している値を見てください。それが session のようなオブジェクトなら boolean に直し、状態が「不明」のあいだに Stack を描いていないかを確かめる——確認はこの 2 点だけで足ります。
私自身、二値で済ませたくなる気持ちはいまもあります。三つ目の状態を足すのは面倒ですし、たいていの起動では一瞬で通り過ぎるからです。それでも、判定できないあいだはどちらも見せないという線だけは、試作アプリでも守るようにしています。