import { useCallback, useEffect, useRef, useState } from "react";
import { AppState, AppStateStatus } from "react-native";
import WebView from "react-native-webview";
/**
* WebView のメモリ解放・プロセス終了による白画面を自動復帰させるフック。
*
* 使い方:
* const { remountKey, remount, processHandlers, pingHandlers, webViewRef } = useWebViewRemount();
*
*
* 既存の ref がある場合は webViewRef を使わず processHandlers / pingHandlers だけ使ってもよい。
* pingHandlers を使う場合は onMessage を上書きせず spread すること。
* ping による白画面検知を有効にするには pingEnabled: true を渡す。
*/
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(null);
const webViewRef = useRef(null);
// ping watchdog 用
const lastPongAt = useRef(Date.now());
const isLoadingRef = useRef(true);
const remount = useCallback(() => {
lastPongAt.current = Date.now();
isLoadingRef.current = true;
setRemountKey((k) => k + 1);
}, []);
// バックグラウンドから復帰したら再マウント(デフォルト5分超)
useEffect(() => {
const onAppStateChange = (nextState: AppStateStatus) => {
if (nextState.match(/inactive|background/)) {
backgroundedAt.current = Date.now();
} else if (nextState === "active" && backgroundedAt.current !== null) {
const elapsed = Date.now() - backgroundedAt.current;
backgroundedAt.current = null;
if (elapsed > backgroundThresholdMs) {
remount();
}
}
};
const subscription = AppState.addEventListener("change", onAppStateChange);
return () => subscription.remove();
}, [remount, backgroundThresholdMs]);
// ping watchdog: 5秒ごとに生存確認と白画面検知を行う
// - ローディング中(isLoadingRef=true)でも45秒超なら remount(レンダラー死亡でonLoadEndが来ないケース)
// - ロード完了後は30秒 pong 無応答で remount
const maxTextLenRef = useRef(0);
const blankCountRef = useRef(0);
useEffect(() => {
if (!pingEnabled) return;
const id = setInterval(() => {
const elapsed = Date.now() - lastPongAt.current;
if (isLoadingRef.current) {
// ローディング中でも45秒超はレンダラー死亡と判定
if (elapsed > 45_000) remount();
return;
}
// ロード完了後30秒 pong 無応答 → レンダラー死亡
if (elapsed > 30_000) {
remount();
return;
}
webViewRef.current?.injectJavaScript(
`(function(){var t=document.body?(document.body.innerText||'').replace(/\\s+/g,'').length:0;window.ReactNativeWebView&&window.ReactNativeWebView.postMessage(JSON.stringify({type:'__ping',len:t}));})();true;`
);
}, 5_000);
return () => clearInterval(id);
}, [pingEnabled, remount]);
const processHandlers = {
onRenderProcessGone: () => remount(),
onContentProcessDidTerminate: () => remount(),
} as const;
// pingHandlers は onLoadEnd と onMessage を提供する
// 既存の onMessage がある場合は手動でマージすること
const pingHandlers = pingEnabled ? {
onLoadEnd: () => {
isLoadingRef.current = false;
lastPongAt.current = Date.now();
maxTextLenRef.current = 0;
blankCountRef.current = 0;
// ロード完了3秒後に初回コンテンツチェック
setTimeout(() => {
if (!isLoadingRef.current) {
webViewRef.current?.injectJavaScript(
`(function(){var t=document.body?(document.body.innerText||'').replace(/\\s+/g,'').length:0;window.ReactNativeWebView&&window.ReactNativeWebView.postMessage(JSON.stringify({type:'__ping',len:t}));})();true;`
);
}
}, 3000);
},
onLoadStart: () => {
isLoadingRef.current = true;
lastPongAt.current = Date.now(); // ナビゲーション開始時にタイムアウトリセット
maxTextLenRef.current = 0;
blankCountRef.current = 0;
},
onMessage: (event: any) => {
try {
const parsed = JSON.parse(event.nativeEvent.data);
if (parsed.type === "__ping") {
lastPongAt.current = Date.now(); // 応答ごとにタイムアウトリセット
const len: number = parsed.len ?? 0;
if (len > maxTextLenRef.current) maxTextLenRef.current = len;
// 一度でも20文字超になったページが5文字未満になったら白画面と判定
if (maxTextLenRef.current > 20 && len < 5) {
blankCountRef.current += 1;
if (blankCountRef.current >= 3) {
blankCountRef.current = 0;
maxTextLenRef.current = 0;
remount();
}
} else {
blankCountRef.current = 0;
}
}
} catch {}
},
} : {};
return { remountKey, remount, processHandlers, pingHandlers, webViewRef };
}