129 lines
5.3 KiB
TypeScript
129 lines
5.3 KiB
TypeScript
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();
|
||
* <WebView key={remountKey} ref={webViewRef} {...processHandlers} {...pingHandlers} ... />
|
||
*
|
||
* 既存の 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<number | null>(null);
|
||
const webViewRef = useRef<WebView>(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 };
|
||
}
|