fix: update useWebViewRemount to include backgroundThresholdMs option for better app state handling

This commit is contained in:
harukin-expo-dev-env
2026-05-01 23:25:50 +00:00
parent 92f4b37861
commit b5eb830734
3 changed files with 327 additions and 101 deletions

View File

@@ -1,5 +1,13 @@
import React, { useState, useEffect } from "react";
import { View, Text, ScrollView, StyleSheet, Image, TouchableOpacity, Linking } from "react-native";
import {
View,
Text,
ScrollView,
StyleSheet,
Image,
TouchableOpacity,
Linking,
} from "react-native";
import { Switch } from "@rneui/themed";
import { useNavigation } from "@react-navigation/native";
import { MaterialCommunityIcons } from "@expo/vector-icons";
@@ -69,7 +77,16 @@ const DataSourceAccordionCard: React.FC<DataSourceAccordionCardProps> = ({
const { colors } = useThemeColors();
return (
<View style={[styles.accordionCard, { backgroundColor: colors.surface, borderColor: colors.borderSecondary }, enabled && styles.accordionCardEnabled]}>
<View
style={[
styles.accordionCard,
{
backgroundColor: colors.surface,
borderColor: colors.borderSecondary,
},
enabled && styles.accordionCardEnabled,
]}
>
{/* ── ヘッダー行(常時表示) ── */}
<View style={styles.accordionHeader}>
{/* 左:ロゴ */}
@@ -77,8 +94,14 @@ const DataSourceAccordionCard: React.FC<DataSourceAccordionCardProps> = ({
{/* 中央:タイトル+タグライン */}
<View style={styles.accordionTitles}>
<Text style={[styles.accordionTitle, { color: colors.textPrimary }]}>{title}</Text>
<Text style={[styles.accordionTagline, { color: colors.textTertiary }]}>{tagline}</Text>
<Text style={[styles.accordionTitle, { color: colors.textPrimary }]}>
{title}
</Text>
<Text
style={[styles.accordionTagline, { color: colors.textTertiary }]}
>
{tagline}
</Text>
</View>
{/* 右:スイッチ */}
@@ -92,19 +115,36 @@ const DataSourceAccordionCard: React.FC<DataSourceAccordionCardProps> = ({
{/* スイッチ状態テキスト */}
<View style={styles.accordionStatusRow}>
<View style={[styles.statusDot, { backgroundColor: enabled ? accentColor : colors.textDisabled }]} />
<Text style={[styles.statusText, { color: enabled ? accentColor : colors.textQuaternary }]}>
{enabled ? "有効 — 編成データを取得します" : "無効 — データを取得しません"}
<View
style={[
styles.statusDot,
{ backgroundColor: enabled ? accentColor : colors.textDisabled },
]}
/>
<Text
style={[
styles.statusText,
{ color: enabled ? accentColor : colors.textQuaternary },
]}
>
{enabled
? "有効 — 編成データを取得します"
: "無効 — データを取得しません"}
</Text>
</View>
{/* ── 展開トリガー ── */}
<TouchableOpacity
style={[styles.accordionToggleRow, { borderTopColor: colors.borderCard }]}
style={[
styles.accordionToggleRow,
{ borderTopColor: colors.borderCard },
]}
onPress={() => setExpanded((v) => !v)}
activeOpacity={0.6}
>
<Text style={[styles.accordionToggleLabel, { color: colors.textSecondary }]}>
<Text
style={[styles.accordionToggleLabel, { color: colors.textSecondary }]}
>
{expanded ? "詳細を閉じる" : (detailLabel ?? `${title} について`)}
</Text>
<MaterialCommunityIcons
@@ -116,31 +156,69 @@ const DataSourceAccordionCard: React.FC<DataSourceAccordionCardProps> = ({
{/* ── 展開コンテンツ ── */}
{expanded && (
<View style={[styles.accordionBody, { borderTopColor: colors.borderCard, backgroundColor: colors.backgroundTertiary }]}>
<View
style={[
styles.accordionBody,
{
borderTopColor: colors.borderCard,
backgroundColor: colors.backgroundTertiary,
},
]}
>
{/* 説明文 */}
<Text style={[styles.bodyDesc, { color: colors.textSecondary }]}>{description}</Text>
<Text style={[styles.bodyDesc, { color: colors.textSecondary }]}>
{description}
</Text>
{/* 機能リスト */}
<View style={[styles.bodyFeatures, { borderTopColor: colors.borderSecondary }]}>
<View
style={[
styles.bodyFeatures,
{ borderTopColor: colors.borderSecondary },
]}
>
{features.map((f) => (
<View key={f.icon} style={styles.featureRow}>
<View style={styles.featureIcon}>
<MaterialCommunityIcons name={f.icon as any} size={14} color={colors.iconSecondary} />
<MaterialCommunityIcons
name={f.icon as any}
size={14}
color={colors.iconSecondary}
/>
</View>
<Text style={[styles.featureLabel, { color: colors.textPrimary }]}>{f.label}</Text>
<Text style={[styles.featureText, { color: colors.textSecondary }]}>{f.text}</Text>
<Text
style={[styles.featureLabel, { color: colors.textPrimary }]}
>
{f.label}
</Text>
<Text
style={[styles.featureText, { color: colors.textSecondary }]}
>
{f.text}
</Text>
</View>
))}
</View>
{/* リンク */}
<TouchableOpacity
style={[styles.bodyLink, { borderTopColor: colors.borderSecondary }]}
style={[
styles.bodyLink,
{ borderTopColor: colors.borderSecondary },
]}
onPress={() => Linking.openURL(linkUrl)}
activeOpacity={0.7}
>
<MaterialCommunityIcons name="open-in-new" size={13} color={colors.iconSecondary} />
<Text style={[styles.bodyLinkText, { color: colors.textSecondary }]}>{linkLabel}</Text>
<MaterialCommunityIcons
name="open-in-new"
size={13}
color={colors.iconSecondary}
/>
<Text
style={[styles.bodyLinkText, { color: colors.textSecondary }]}
>
{linkLabel}
</Text>
</TouchableOpacity>
</View>
)}
@@ -152,17 +230,45 @@ const DataSourceAccordionCard: React.FC<DataSourceAccordionCardProps> = ({
/* 定数 */
/* ------------------------------------------------------------------ */
const UNYOHUB_FEATURES: Feature[] = [
{ icon: "calendar-today", label: "運用データ", text: "当日・過去数日から投稿があった運用の継続予測運用情報を表示" },
{ icon: "map-outline", label: "対象エリア", text: "JR四国全線" },
{ icon: "train", label: "対象運用", text: "JR四国管内営業列車及び貨物列車,定期回送列車に対応、臨時列車/突発運用は非対応" },
{ icon: "pencil", label: "入力方式", text: "アプリ内連携システムにて当日の運用の投稿が可能" },
{
icon: "calendar-today",
label: "運用データ",
text: "当日・過去数日から投稿があった運用の継続予測運用情報を表示",
},
{ icon: "map-outline", label: "対象エリア", text: "JR四国全線" },
{
icon: "train",
label: "対象運用",
text: "JR四国管内営業列車及び貨物列車,定期回送列車に対応、臨時列車/突発運用は非対応",
},
{
icon: "pencil",
label: "入力方式",
text: "アプリ内連携システムにて当日の運用の投稿が可能",
},
];
const ELESITE_FEATURES: Feature[] = [
{ icon: "calendar-today", label: "運用データ", text: "当日報告のあった運用情報のみ表示" },
{ icon: "map-outline", label: "対象エリア", text: "予讃線/瀬戸大橋線(直通している特急などの列番は含みます)" },
{ icon: "train", label: "対象運用", text: "JR四国管内営業列車対応、臨時列車/突発運用は非対応" },
{ icon: "pencil", label: "入力方式", text: "アプリ外リンク連携にて当日の運用の投稿が可能" },
{
icon: "calendar-today",
label: "運用データ",
text: "当日報告のあった運用情報のみ表示",
},
{
icon: "map-outline",
label: "対象エリア",
text: "予讃線/瀬戸大橋線(直通している特急などの列番は含みます)",
},
{
icon: "train",
label: "対象運用",
text: "JR四国管内営業列車対応、臨時列車/突発運用は非対応",
},
{
icon: "pencil",
label: "入力方式",
text: "アプリ外リンク連携にて当日の運用の投稿が可能",
},
];
/* ------------------------------------------------------------------ */
@@ -170,17 +276,17 @@ const ELESITE_FEATURES: Feature[] = [
/* ------------------------------------------------------------------ */
export const DataSourceSettings = () => {
const navigation = useNavigation();
const { updatePermission, mockApiFeatureEnabled, setMockApiFeatureEnabled } = useTrainMenu();
const { updatePermission, mockApiFeatureEnabled, setMockApiFeatureEnabled } =
useTrainMenu();
const { colors, fixed } = useThemeColors();
const showDebugSelector = __DEV__ || updatePermission;
const [useUnyohub, setUseUnyohub] = useState(false);
const [useElesite, setUseElesite] = useState(false);
const [jrDataSystemEnv, setJrDataSystemEnv] =
useState<JrDataSystemEnvironmentKey>(DEFAULT_JR_DATA_SYSTEM_ENV);
const [jrDataSystemTrack, setJrDataSystemTrack] =
useState<JrDataSystemTrack>(
getJrDataSystemTrack(DEFAULT_JR_DATA_SYSTEM_ENV),
);
const [jrDataSystemTrack, setJrDataSystemTrack] = useState<JrDataSystemTrack>(
getJrDataSystemTrack(DEFAULT_JR_DATA_SYSTEM_ENV),
);
const [jrDataSystemUiVariant, setJrDataSystemUiVariant] =
useState<JrDataSystemUiVariant>(
getJrDataSystemUiVariant(DEFAULT_JR_DATA_SYSTEM_ENV),
@@ -228,7 +334,8 @@ export const DataSourceSettings = () => {
const handleSelectJrDataSystemTrack = (value: JrDataSystemTrack) => {
setJrDataSystemTrack(value);
const normalizedVariant = value === "experimental" ? "release" : jrDataSystemUiVariant;
const normalizedVariant =
value === "experimental" ? "release" : jrDataSystemUiVariant;
setJrDataSystemUiVariant(normalizedVariant);
const env = resolveJrDataSystemEnvironment(value, normalizedVariant);
applyJrDataSystemEnv(env);
@@ -250,55 +357,100 @@ export const DataSourceSettings = () => {
position: "left",
}}
/>
<ScrollView style={[styles.content, { backgroundColor: colors.backgroundSecondary }]} contentContainerStyle={styles.contentInner}>
<Text style={[styles.sectionTitle, { color: colors.textTertiary }]}></Text>
<ScrollView
style={[
styles.content,
{ backgroundColor: colors.backgroundSecondary },
]}
contentContainerStyle={styles.contentInner}
>
<Text style={[styles.sectionTitle, { color: colors.textTertiary }]}>
</Text>
<DataSourceAccordionCard
logo={HUB_LOGO_PNG}
accentColor="#0099CC"
title="鉄道運用Hub"
tagline="コミュニティによる列車運用情報サービス"
enabled={useUnyohub}
onToggle={handleToggleUnyohub}
description={
"鉄道運用Hubはオープンソースのユーザー投稿型鉄道運用情報データベースアプリケーションです。JR 四国をはじめ全国多数の路線系統に対応しています。\n\nデータがある列車では地図上にアイコンでマークが表示され、列車情報画面の編成表示も更新されます。"
}
features={UNYOHUB_FEATURES}
linkLabel="unyohub.2pd.jp を開くJR四国"
linkUrl="https://unyohub.2pd.jp/railroad_shikoku/"
/>
<DataSourceAccordionCard
logo={HUB_LOGO_PNG}
accentColor="#0099CC"
title="鉄道運用Hub"
tagline="コミュニティによる列車運用情報サービス"
enabled={useUnyohub}
onToggle={handleToggleUnyohub}
description={
"鉄道運用Hubはオープンソースのユーザー投稿型鉄道運用情報データベースアプリケーションです。JR 四国をはじめ全国多数の路線系統に対応しています。\n\nデータがある列車では地図上にアイコンでマークが表示され、列車情報画面の編成表示も更新されます。"
}
features={UNYOHUB_FEATURES}
linkLabel="unyohub.2pd.jp を開くJR四国"
linkUrl="https://unyohub.2pd.jp/railroad_shikoku/"
/>
<DataSourceAccordionCard
logo={ELESITE_LOGO_PNG}
accentColor="#44bb44"
title="えれサイト"
tagline="コミュニティによる列車運用情報サービス"
enabled={useElesite}
onToggle={handleToggleElesite}
description={
"えれサイトは、鉄道の運用情報を利用者同士で共有するサービスです。皆様からの投稿をもとに、列車のリアルタイムな動きを反映しています。JR四国の特急・普通列車をはじめ、現在は全国の路線に対応しています。\n\nデータがある列車では地図上にアイコンでマークが表示され、列車情報画面の編成表示も更新されます。"
}
features={ELESITE_FEATURES}
linkLabel="elesite-next.com を開く"
linkUrl="https://www.elesite-next.com/"
/>
<DataSourceAccordionCard
logo={ELESITE_LOGO_PNG}
accentColor="#44bb44"
title="えれサイト"
tagline="コミュニティによる列車運用情報サービス"
enabled={useElesite}
onToggle={handleToggleElesite}
description={
"えれサイトは、鉄道の運用情報を利用者同士で共有するサービスです。皆様からの投稿をもとに、列車のリアルタイムな動きを反映しています。JR四国の特急・普通列車をはじめ、現在は全国の路線に対応しています。\n\nデータがある列車では地図上にアイコンでマークが表示され、列車情報画面の編成表示も更新されます。"
}
features={ELESITE_FEATURES}
linkLabel="elesite-next.com を開く"
linkUrl="https://www.elesite-next.com/"
/>
<View style={[styles.infoSection, { backgroundColor: colors.backgroundTertiary }]}>
<Text style={[styles.infoText, { color: colors.textCaution }]}>
{"\n\n"}
{"\n\n"}JR四国非公式アプリが管理していないデータであるため
</Text>
</View>
<View
style={[
styles.infoSection,
{ backgroundColor: colors.backgroundTertiary },
]}
>
<Text style={[styles.infoText, { color: colors.textCaution }]}>
{"\n\n"}
{"\n\n"}
JR四国非公式アプリが管理していないデータであるため
</Text>
</View>
{showDebugSelector && (
<View style={[styles.debugSection, { backgroundColor: colors.surface, borderColor: colors.borderSecondary }]}>
<Text style={[styles.debugTitle, { color: colors.textPrimary }]}>デバッグ: モックAPI検証</Text>
<Text style={[styles.debugDescription, { color: colors.textSecondary }]}>
APIスイッチを表示し
{showDebugSelector && (
<>
<View
style={[
styles.debugSection,
{
backgroundColor: colors.surface,
borderColor: colors.borderSecondary,
},
]}
>
<Text style={[styles.debugTitle, { color: colors.textPrimary }]}>
デバッグ: モックAPI検証
</Text>
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", marginTop: 8 }}>
<Text style={[styles.debugCurrentText, { color: colors.textPrimary, fontSize: 14 }]}>API検証機能</Text>
<Text
style={[
styles.debugDescription,
{ color: colors.textSecondary },
]}
>
</Text>
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
marginTop: 8,
}}
>
<Text
style={[
styles.debugCurrentText,
{ color: colors.textPrimary, fontSize: 14 },
]}
>
API検証機能
</Text>
<Switch
value={mockApiFeatureEnabled}
onValueChange={setMockApiFeatureEnabled}
@@ -306,19 +458,48 @@ export const DataSourceSettings = () => {
/>
</View>
</View>
)}
<View style={[styles.debugSection, { backgroundColor: colors.surface, borderColor: colors.borderSecondary }]}>
<Text style={[styles.debugTitle, { color: colors.textPrimary }]}>稿</Text>
<Text style={[styles.debugDescription, { color: colors.textSecondary }]}>
<View
style={[
styles.debugSection,
{
backgroundColor: colors.surface,
borderColor: colors.borderSecondary,
},
]}
>
<Text style={[styles.debugTitle, { color: colors.textPrimary }]}>
稿
</Text>
<Text
style={[
styles.debugDescription,
{ color: colors.textSecondary },
]}
>
/
</Text>
<Text style={[styles.debugCurrentText, { color: colors.textTertiary }]}></Text>
<Text
style={[
styles.debugCurrentText,
{ color: colors.textTertiary },
]}
>
</Text>
<View style={styles.debugOptionRow}>
{[
{ key: "production" as const, label: "本番", caption: "一般公開向け" },
{ key: "experimental" as const, label: "実験", caption: "毎日リセット" },
{
key: "production" as const,
label: "本番",
caption: "一般公開向け",
},
{
key: "experimental" as const,
label: "実験",
caption: "毎日リセット",
},
].map((option) => {
const selected = jrDataSystemTrack === option.key;
return (
@@ -341,7 +522,11 @@ export const DataSourceSettings = () => {
<Text
style={[
styles.debugOptionTitle,
{ color: selected ? fixed.textOnPrimary : colors.textPrimary },
{
color: selected
? fixed.textOnPrimary
: colors.textPrimary,
},
]}
>
{option.label}
@@ -349,7 +534,11 @@ export const DataSourceSettings = () => {
<Text
style={[
styles.debugOptionCaption,
{ color: selected ? fixed.textOnPrimary : colors.textTertiary },
{
color: selected
? fixed.textOnPrimary
: colors.textTertiary,
},
]}
>
{option.caption}
@@ -359,11 +548,26 @@ export const DataSourceSettings = () => {
})}
</View>
<Text style={[styles.debugCurrentText, { color: colors.textTertiary }]}>UIバージョン</Text>
<Text
style={[
styles.debugCurrentText,
{ color: colors.textTertiary },
]}
>
UIバージョン
</Text>
<View style={styles.debugOptionRow}>
{[
{ key: "release" as const, label: "リリース", caption: "安定版" },
{ key: "beta" as const, label: "ベータ", caption: "夜間ビルド" },
{
key: "release" as const,
label: "リリース",
caption: "安定版",
},
{
key: "beta" as const,
label: "ベータ",
caption: "夜間ビルド",
},
].map((option) => {
const selected = jrDataSystemUiVariant === option.key;
const disabled = jrDataSystemTrack === "experimental";
@@ -391,7 +595,11 @@ export const DataSourceSettings = () => {
<Text
style={[
styles.debugOptionTitle,
{ color: selected ? fixed.textOnPrimary : colors.textPrimary },
{
color: selected
? fixed.textOnPrimary
: colors.textPrimary,
},
]}
>
{option.label}
@@ -399,7 +607,11 @@ export const DataSourceSettings = () => {
<Text
style={[
styles.debugOptionCaption,
{ color: selected ? fixed.textOnPrimary : colors.textTertiary },
{
color: selected
? fixed.textOnPrimary
: colors.textTertiary,
},
]}
>
{option.caption}
@@ -408,9 +620,23 @@ export const DataSourceSettings = () => {
);
})}
</View>
<Text style={[styles.debugCurrentText, { color: colors.textTertiary }]}>: {JR_DATA_SYSTEM_ENV_OPTIONS.find((option) => option.key === jrDataSystemEnv)?.baseUrl}</Text>
<Text
style={[
styles.debugCurrentText,
{ color: colors.textTertiary },
]}
>
:{" "}
{
JR_DATA_SYSTEM_ENV_OPTIONS.find(
(option) => option.key === jrDataSystemEnv,
)?.baseUrl
}
</Text>
</View>
</ScrollView>
</>
)}
</ScrollView>
</View>
);
};

View File

@@ -13,8 +13,9 @@ import WebView from "react-native-webview";
* pingHandlers を使う場合は onMessage を上書きせず spread すること。
* ping による白画面検知を有効にするには pingEnabled: true を渡す。
*/
export function useWebViewRemount(options?: { pingEnabled?: boolean }) {
export function useWebViewRemount(options?: { pingEnabled?: boolean; backgroundThresholdMs?: number }) {
const pingEnabled = options?.pingEnabled ?? false;
const backgroundThresholdMs = options?.backgroundThresholdMs ?? 300_000; // デフォルト5分
const [remountKey, setRemountKey] = useState(0);
const backgroundedAt = useRef<number | null>(null);
const webViewRef = useRef<WebView>(null);
@@ -29,7 +30,7 @@ export function useWebViewRemount(options?: { pingEnabled?: boolean }) {
setRemountKey((k) => k + 1);
}, []);
// バックグラウンド10秒超から復帰したら再マウント
// バックグラウンドから復帰したら再マウントデフォルト5分超
useEffect(() => {
const onAppStateChange = (nextState: AppStateStatus) => {
if (nextState.match(/inactive|background/)) {
@@ -37,14 +38,14 @@ export function useWebViewRemount(options?: { pingEnabled?: boolean }) {
} else if (nextState === "active" && backgroundedAt.current !== null) {
const elapsed = Date.now() - backgroundedAt.current;
backgroundedAt.current = null;
if (elapsed > 10_000) {
if (elapsed > backgroundThresholdMs) {
remount();
}
}
};
const subscription = AppState.addEventListener("change", onAppStateChange);
return () => subscription.remove();
}, [remount]);
}, [remount, backgroundThresholdMs]);
// ping watchdog: 5秒ごとに生存確認と白画面検知を行う
// - ローディング中isLoadingRef=trueでも45秒超なら remountレンダラー死亡でonLoadEndが来ないケース

View File

@@ -16,20 +16,19 @@ import { useThemeColors } from "@/lib/theme";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useWebViewRemount } from "@/lib/useWebViewRemount";
export default function tndView() {
const { remountKey, remount, processHandlers, pingHandlers, webViewRef } = useWebViewRemount({ pingEnabled: true });
const { remountKey, remount, processHandlers, pingHandlers, webViewRef } = useWebViewRemount({ pingEnabled: true, backgroundThresholdMs: 60_000 }); // 1分超でAppState remount
const { navigate, addListener, isFocused } = useNavigation();
const { fixed } = useThemeColors();
const { top } = useSafeAreaInsets();
// タブ切り替え時の自動復帰: バックグラウンドから戻ったとき remount
// タブにフォーカスが来たとき、バックグラウンドから戻ってきた場合 remount
const bgAtRef = useRef<number | null>(null);
useEffect(() => {
const sub = AppState.addEventListener("change", (state) => {
if (state.match(/inactive|background/)) {
bgAtRef.current = Date.now();
} else if (state === "active" && bgAtRef.current !== null) {
// 10秒超はuseWebViewRemount側が処理するのでここではクリアのみ
if (Date.now() - bgAtRef.current > 10_000) bgAtRef.current = null;
} else if (state === "active") {
// useWebViewRemount側(1分超)とは独立: ここではフォーカス時判定用に保持するだけ
}
});
return () => sub.remove();