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 }; }