- Removed direct usage of favorite station state in multiple components (MenuPage, StationDetailView, WebView, FavoriteList, FavoriteSettings). - Introduced utility functions for handling favorite station keys and normalization. - Updated favorite station context to include methods for adding, removing, and toggling favorite stations. - Implemented normalization logic to ensure consistency with the current station master. - Added documentation for the favorite station normalization plan. - Improved error handling and data validation for favorite stations.
12 KiB
お気に入り駅の正規化・削除済み駅対策 実装計画
1. 目的
端末の永続ストレージに残った旧形式・重複・削除済みのお気に入り駅を、安全に現在の駅マスターへ移行する。
この対応で、主に次の2件を同時に解消する。
- 津島ノ宮(
Y13-x)を駅マスターから削除した後、保存済みのお気に入り参照によってアプリがクラッシュする問題 - 徳島・高知・多度津など、複数路線の駅番号を持つ駅が「消せないお気に入り」または重複したお気に入りとして表示される問題
2. 対象ブランチと反映手順
- 実装元(Android側):
hotfix/support-tsushimanomiya-temp - チェリーピック先:
hotfix/tsusimanomiya-old
実装・検証・コミットは必ずAndroid側ブランチで先に行い、確定したコミットを旧側ブランチへチェリーピックする。両ブランチへ別々に同内容を書き込まない。
3. 現状と原因
3.1 EAS更新と端末ストレージの世代不一致
EAS UpdateでJavaScriptとアセットを以前の状態へ戻しても、AsyncStorage内の favoriteStation は更新前の値を保持する。
津島ノ宮追加版で Y13-x を保存した端末が、津島ノ宮を含まない版を受け取ると次の状態になる。
- 保存済みお気に入りには
Y13-xが存在する - 現在の駅マスターには
Y13-xが存在しない getStationDataFromId("Y13-x")が空配列を返す- 画面が
currentStationData[0]を参照してクラッシュする
3.2 複数路線駅の旧形式不整合
同じ物理駅に複数の駅番号が割り当てられている。
| 物理駅 | 駅番号 |
|---|---|
| 徳島 | T00, B00 |
| 高知 | D45, K00 |
| 多度津 | Y12, D12 |
現在の getStationDataFromId() は、1つの駅番号から駅名を引き直し、同名の全路線データをまとめて返す。このため T00 の検索結果は [T00, B00] になる。
一方、お気に入りの登録判定と解除は、駅グループ全体の JSON.stringify() 完全一致に依存している。端末に旧形式 [T00] が残っている場合、現在形式 [T00, B00] と一致しない。
その結果、旧形式の徳島を解除しようとした操作が新形式の徳島追加として処理され、2件表示になる。次の解除では新形式だけが消え、旧形式が残るため、利用者からは「消せない徳島駅」に見える。
3.3 現行移行処理の問題
現行の lodAddMigration() には次の問題がある。
- 変換後のデータをReact stateに入れるだけで、AsyncStorageへ保存し直していない
- 現在の駅マスターに存在しない駅を空配列のまま残す
- 同じ物理駅の重複を除去しない
- 先頭要素に
jslodApiがある旧データでは移行が開始されない - 表示コンポーネント
AddressTextから移行副作用を起動している
3.4 駅マスター読み込み前のレース
Sign は useState(getStationDataFromId(stationID)) で初回検索結果を固定している。
駅マスターの非同期読み込み前に描画されると空配列がstateへ固定され、駅マスター読み込み後も再検索されない。その後の [0] 参照でクラッシュする可能性がある。削除済み駅対策と同時に、この起動時レースも解消する必要がある。
4. 設計方針
4.1 お気に入りの物理駅キー
お気に入りの同一性は、駅データ配列全体や単一の駅番号ではなく「物理駅」を表す安定キーで判定する。
当面のキー仕様は次の通りとする。
- 有効な先頭要素の
Station_JPをtrimした文字列 - 駅名が取得できない場合のみ、有効な
StationNumberをフォールバックとして使用 - 空配列、駅名・駅番号の双方がない値は無効
対応路線内では、徳島・高知・多度津のような同一駅名は同一の物理駅として扱う。将来、同名だが別の物理駅を収録する場合は、専用の物理駅IDを駅マスターへ追加する。
4.2 保存形式
今回のホットフィックスでは影響範囲を抑えるため、保存形式 StationProps[][] は維持する。
ただし、保存される各要素は必ず現在の駅マスターから再構築した正規形とする。
- 徳島は常に
[T00, B00] - 高知は常に
[D45, K00] - 多度津は常に
[Y12, D12] - 存在しない駅は保存しない
- 同じ物理駅は1件だけ保存する
5. 実装内容
フェーズA: 純粋な正規化処理の追加
お気に入り処理を表示コンポーネントから分離し、テスト可能な純粋関数として実装する。
想定する処理は次の通り。
- ストレージ値が配列か検証する
- 各要素から有効な駅オブジェクトを1件探す
- 駅名を使って現在の駅マスターから同名駅グループを取得する
- 駅名がない場合は駅番号検索をフォールバックとして使う
- 現在の駅マスターで解決できない駅を除外する
- 物理駅キーで重複を除外する
- 元のお気に入り順を維持する
- 正規化済み配列を返す
正規化処理は同じ入力へ複数回適用しても結果が変わらない、冪等な処理にする。
フェーズB: 起動時ロードと永続化の統合
FavoriteStationProvider の起動処理を次の順番へ変更する。
- 駅マスターの読み込み完了を待つ
favoriteStationをAsyncStorageから読み込む- JSONおよび配列構造を検証する
- 現在の駅マスターへ正規化する
- React stateを正規化結果で更新する
- 読み込み値と正規化結果が異なる場合だけAsyncStorageへ保存する
空・破損・旧形式の値はクラッシュさせず、空のお気に入りとして復旧する。ストレージの読み書き失敗は既存loggerへ記録する。
現在の lodAddMigration() と、AddressText から移行を起動する処理は削除する。
フェーズC: お気に入り操作APIの一元化
FavoriteStationContext に次の操作を集約する。
isFavoriteStation(stationGroup)addFavoriteStation(stationGroup)removeFavoriteStation(stationGroup)toggleFavoriteStation(stationGroup)- 並び替え用の
replaceFavoriteStations(stationGroups)
登録・解除は物理駅キーで判定し、JSON.stringify() 完全一致を廃止する。
更新時は配列を直接 push() せず、新しい配列を生成する。state更新とAsyncStorage保存は同じ正規化済みデータを使用する。
フェーズD: 表示側の防御
Sign を次のように変更する。
- 駅マスター読み込み後または
stationID変更後に駅データを再解決する - 初回検索結果を
useStateへ固定しない - 解決結果が空の場合は
[0]を参照せず、安全なプレースホルダーまたは非表示を返す - お気に入り状態とトグル操作はContextの物理駅キーAPIを使う
合わせて、次の利用箇所でも空配列を防御する。
- お気に入りカルーセル
- お気に入りクイック移動
- お気に入り並び替え設定
- WebView初期移動での先頭お気に入り参照
- 駅詳細画面
正規化が正常に動いた場合でも、描画側の防御は将来の駅マスター変更やストレージ破損に備えて恒久的に残す。
フェーズE: 津島ノ宮削除への備え
この計画の実装時点では、津島ノ宮の駅マスター・走行位置インジェクションを直ちに削除しない。
先に正規化・防御版を配信して動作を確認する。その後、津島ノ宮を削除する版では、起動時正規化が Y13-x を解決不能として除外することを確認する。
最新バージョンへ直接更新する利用者もいるため、津島ノ宮削除版にも正規化処理と空配列防御を必ず含める。
6. 変更予定ファイル
主な対象は次の通り。実装時の責務分割に応じてファイル名は調整する。
stateBox/useFavoriteStation.tsx- 起動時正規化、永続化、操作APIの一元化
stateBox/useStationList.tsx- 駅マスター準備状態の提供、駅検索関数の安定化
components/駅名表/Sign.tsx- 再解決、空配列防御、物理駅キーによる登録・解除
components/駅名表/AddressText.tsx- 表示中に実行している旧移行処理の撤去
components/FavoriteList.tsx- 無効データ防御
components/Settings/FavoriteSettings.tsx- 安定キーと正規化済み並び替え保存
components/Apps/WebView.tsx- 先頭お気に入り参照の防御
lib/favoriteStationUtils.ts(新規候補)- 物理駅キー、検証、正規化、重複排除の純粋関数
7. 検証計画
7.1 正規化ケース
- 現行形式の通常駅1件は変更されない
- 旧形式の徳島
[T00]が[T00, B00]へ変換される - 旧形式の高知
[D45]が[D45, K00]へ変換される - 旧形式の多度津
[Y12]が[Y12, D12]へ変換される [T00]と[T00, B00]が共存していても徳島1件になる- 同じ物理駅が複数回保存されていても最初の1件だけ残る
Y13-xが駅マスターに存在しない条件では津島ノ宮が除外される- 空配列、
null、壊れたオブジェクトが除外される - お気に入りの並び順が維持される
- 正規化を2回適用しても結果が変化しない
7.2 操作ケース
- 徳島・高知・多度津を1回の操作で登録できる
- 同じ駅を別路線側の駅番号から開いても重複登録されない
- どちらの路線側からでも1回で解除できる
- 解除後に同じ駅がもう1件現れない
- 通常駅の登録・解除に退行がない
- 並び替え後も正規形と順番が保存される
7.3 起動・更新ケース
- 駅マスター読み込み前にお気に入りが復元されてもクラッシュしない
- 削除済み
Y13-xを含むストレージで起動してもクラッシュしない - ストレージが空、未作成、壊れたJSONでも起動できる
- 正規化が必要な場合だけAsyncStorageが更新される
- アプリ再起動後も正規化結果が維持され、毎回移行されない
- EAS Update適用後およびロールバック相当のデータ組み合わせで起動できる
7.4 静的・実機確認
npx tsc --noEmitgit diff --check- Android実機でお気に入りカルーセル、クイック移動、設定画面、駅詳細を確認
- Sentryで
StationNumber of undefined、Station_JP of undefined、お気に入り関連クラッシュの再発を確認
テストフレームワークを新規導入する大規模変更は避ける。正規化ロジックは純粋関数にし、必要であれば npx tsx で実行できる小さな回帰確認スクリプトを追加する。
8. ロールアウト手順
- Android側ブランチでフェーズA〜Dを実装
- 型チェック、差分チェック、手動フィクスチャ検証を実施
- Android側ブランチでコミット
- 旧側ブランチへコミットをチェリーピック
- 両ブランチの差分と型チェックを確認
- 対象EASチャンネルへ防御版を配信
- Sentryおよび利用者報告でクラッシュ・重複問題を監視
- 問題がないことを確認後、別コミットで津島ノ宮の削除を実施
9. 完了条件
- 削除済み駅を含むお気に入りデータでアプリがクラッシュしない
- 存在しない駅は起動時にstateとAsyncStorageの両方から除外される
- 徳島・高知・多度津が物理駅単位で1件に正規化される
- 複数路線駅を1回で登録・解除でき、重複が再生成されない
- 旧移行処理が表示コンポーネントから撤去されている
- 正規化処理が冪等で、アプリ再起動後も同じ結果になる
- Android側で先行コミットし、そのコミットを旧側へチェリーピックできる状態になっている
10. 対象外
- この計画書作成時点での津島ノ宮データ削除
- お気に入り保存形式を駅IDだけの新スキーマへ全面変更すること
- 対応路線外に存在する同名別駅への一般化
- お気に入りUIのデザイン変更