まえがき
今回は、自社の調剤薬局で患者様にご利用いただいている
処方箋送信アプリ(LIFFアプリ)の実装でハマったポイントを
学習メモとして記載しています。
実装では、同一のLIFF IDを使用し
クエリパラメータによって表示画面を切り替える構成にしていました。
https://liff.line.me/XXXX/?page=prescription
https://liff.line.me/XXXX/?page=address
具体的には、同一のLIFF IDに対して
クエリパラメータだけを変更した2つのURLを用意し
それぞれ別の導線からユーザーに案内していました。
- ?page=prescription はリッチメニューからアクセス
- ?page=address はトーク画面のメッセージ内ボタンからアクセス
この構成は、PCブラウザでは問題なく動作していました。
しかし、LINEアプリ内の LIFFブラウザ で開いた場合に限り
画面が真っ白になる現象が発生しました。
PCブラウザでは発生せず
スマートフォンのLIFFブラウザでのみ発生するため
原因の切り分けに時間がかかりました。
この記事では、以下を整理するために記載しました。
- LIFFの前提仕様(WebView / セッション /
liff.init) - ログからどう切り分けたか
- Rails + Stimulus 構成での実装対策
※用語整理
本記事で扱う不具合は「LINE内ブラウザ全体」ではなく
LIFF URLで起動される「LIFFブラウザ」での動作不良です。
下記記事が非常に参考になりました
発生していた症状
| 操作 | 結果 |
|---|---|
?page=prescription を開いて閉じる |
正常 |
?page=address を開く |
白画面 |
| LINEアプリを完全終了して再起動 | 一時的に正常 |
| 順番を逆にする | 後から開いた方が白画面 |
サーバログは以下のように 200 で返っており、HTTP的には成功していました。
GET /user/sign_in 200
つまり、問題はサーバレスポンス後のフロント側処理にあると判断できます。
LIFFで押さえるべき前提
1. LIFFはLIFFブラウザ(LINE内WebView)で動く
LIFFはLINEアプリ内ブラウザで動作し、Cookieやセッション状態が残ります。
そのため、"LIFFを閉じる" と "セッションが消える" は同義ではありません。
- 公式: LIFFブラウザ
2. 同一LIFF IDでも、画面はクエリで分岐しているだけ
/user/sign_in?page=prescription
/user/sign_in?page=address
エンドポイント自体は同じで、どの画面へ進めるかはフロントの分岐に依存します。
3. liff.init() は多重実行を避けるべき
公式リファレンスにURL操作などは liff.init() の Promise が resolve した後に実行する前提で説明されており、liff.ready も初回初期化完了を基準にした設計です。
そのため実務上は、liff.init() を初期化ライフサイクルとして1回に制御し、完了後に後続処理へ進む実装が安全です。
- 公式: LIFFアプリを初期化する
4. liff.init() の初期化で何が起きるか
liff.init() は、単なる関数呼び出しではなく
LIFFアプリを利用可能状態にするための初期化処理です。
内部ではログイン状態やLIFFコンテキストの確定
必要に応じたリダイレクトなどが発生します。
そのため、見た目上は同じページ表示でも、実際には以下のような状態遷移が起きます。
- 初回アクセス時は認可・リダイレクトが挟まることがある
- 戻り後に
liff.init()が完了して初めて画面処理を安全に進められる - 同じタイミングで複数
initが走ると、競合して失敗しやすい
この前提があるため、今回の白画面は「画面遷移の不具合」ではなく「初期化競合によるフロント停止」と整理できました。
原因
今回の構成では、以下2つのControllerが存在していました。
sign_in_up_controllerliff_profile_sync_controller
そして、ログイン済み状態では両方の初期化経路が動く可能性がありました。
-
sign_in_up_controller側でliff.init(...) -
liff_profile_sync_controller側でもliff.init(...)
liff.init が競合すると、Promise reject を契機に処理が途中停止し
結果として画面が描画されず白画面になります。
なぜ2回目に起きやすいか
初回は未ログインで単一経路になりやすい一方
2回目はCookieが残っていてログイン済み扱いのため
複数経路が同時に走りやすくなります。
なぜLINE再起動で直るように見えるか
LIFFブラウザのWebView状態が実質的にリセットされ
再び単一経路になって再現しにくくなるためです。
修正前コード
問題発生時は、サインイン画面でも別Controllerが描画される条件になっており
liff.init の実行箇所が2つありました。
1. sign_in_up_controller 側で liff.init
// app/javascript/controllers/sign_in_up_controller.js
async connect() {
const res = await axios.get("/api/configs/liff_id");
await liff.init(res.data);
// ...
}
2. liff_profile_sync_controller 側でも liff.init
// app/javascript/controllers/liff_profile_sync_controller.js
async connect() {
const res = await axios.get("/api/configs/liff_id");
await liff.init(res.data);
// ...
}
3. レイアウトでサインイン画面にも同期Controllerを描画
<%# app/views/layouts/application.html.erb %>
<% if user_signed_in? && current_user.provider == "line" %>
<div data-controller="liff-profile-sync"></div>
<% end %>
上記の組み合わせにより、ログイン済み状態では liff.init が並列実行される条件が成立していました。
対策
1. Devise画面では同期処理Controllerを描画しない
レイアウト側で devise_controller? を条件にして
サインインページでは liff-profile-sync を動かさないようにしました。
<% if user_signed_in? && current_user.provider == "line" && !devise_controller? %>
<div data-controller="liff-profile-sync"></div>
<% end %>
2. liff.init の多重実行ガードを入れる
window フラグで初期化済みを管理し、2回目以降はスキップします。
async initializeLiff() {
if (window.__liffInitialized) return;
const res = await axios.get("/api/configs/liff_id");
await liff.init(res.data);
window.__liffInitialized = true;
}
3. エラーハンドリングとフォールバックUIを用意する
sign_in_up_controller 側では try/catch と再試行UIを入れて
白画面のまま止まらないようにしました。
try {
await liff.init(liffId);
// ...
} catch (error) {
console.error("LIFF error:", error);
this.showError("読み込みに失敗しました。");
}
4. page パラメータは安全に解析する
window.location.search だけでなく liff.state からも取り出せるようにし、?page=null の混入を避ける用に修正してます。
extractDestinationPage() {
const urlParams = new URLSearchParams(window.location.search);
const page = urlParams.get("page");
if (page) return page;
const liffState = urlParams.get("liff.state");
if (!liffState) return null;
try {
const url = new URL(liffState, window.location.origin);
return url.searchParams.get("page");
} catch {
return null;
}
}
5. redirectUri は条件付きで生成する
const redirectUri = destinationPage
? `${window.location.origin}/user/sign_in?page=${destinationPage}`
: `${window.location.origin}/user/sign_in`;
liff.login({ redirectUri });
まとめ
- 本件はサーバエラーではなく、LIFF初期化の競合によるフロント停止が本質でした。
- モバイルのLIFFブラウザはセッションが残るため、PCでは再現しない不具合が起こり得る。
-
liff.initをどこで何回実行するかを明確化し、単一経路に制御することが重要でした。 - あわせて例外処理とフォールバックUIを入れると、白画面のままユーザーが詰む状態を防げます。
同様の構成(Rails + Devise + Stimulus + LIFF)で複数導線を扱う場合は、まず liff.init の実行箇所を一覧化して、重複がないかを最初に確認するのが良いかと思います。
記事として何か足りていない内容がありそうですが
一旦まとめておきます。
参考文献
