209 lines
8.3 KiB
TypeScript
209 lines
8.3 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { AppState, AppStateStatus } from "react-native";
|
|
import WebView from "react-native-webview";
|
|
|
|
type WebViewRemountReason =
|
|
| "manual"
|
|
| "app_background"
|
|
| "render_process_gone"
|
|
| "content_process_terminated"
|
|
| "loading_timeout"
|
|
| "pong_timeout"
|
|
| "blank_detected";
|
|
|
|
type WebViewRemountData = Record<string, string | number | boolean | null | undefined>;
|
|
|
|
type UseWebViewRemountOptions = {
|
|
pingEnabled?: boolean;
|
|
backgroundThresholdMs?: number | null;
|
|
isFocused?: boolean;
|
|
pauseWatchdogWhenUnfocused?: boolean;
|
|
ignoreProcessTerminationWhenUnfocused?: boolean;
|
|
onRemount?: (reason: WebViewRemountReason, data?: WebViewRemountData) => void;
|
|
};
|
|
|
|
/**
|
|
* 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?: UseWebViewRemountOptions) {
|
|
const pingEnabled = options?.pingEnabled ?? false;
|
|
const backgroundThresholdMs = options?.backgroundThresholdMs ?? null;
|
|
const isFocused = options?.isFocused ?? true;
|
|
const pauseWatchdogWhenUnfocused = options?.pauseWatchdogWhenUnfocused ?? false;
|
|
const ignoreProcessTerminationWhenUnfocused = options?.ignoreProcessTerminationWhenUnfocused ?? false;
|
|
const onRemount = options?.onRemount;
|
|
const [remountKey, setRemountKey] = useState(0);
|
|
const backgroundedAt = useRef<number | null>(null);
|
|
const webViewRef = useRef<WebView>(null);
|
|
const ignoredProcessTerminationRef = useRef<WebViewRemountReason | null>(null);
|
|
const focusedRef = useRef(isFocused);
|
|
const lastBlurAtRef = useRef<number | null>(null);
|
|
const processTerminationBlurGraceMs = 1200;
|
|
|
|
// ping watchdog 用
|
|
const lastPongAt = useRef(Date.now());
|
|
const isLoadingRef = useRef(true);
|
|
|
|
const triggerRemount = useCallback((reason: WebViewRemountReason, data?: WebViewRemountData) => {
|
|
onRemount?.(reason, data);
|
|
lastPongAt.current = Date.now();
|
|
isLoadingRef.current = true;
|
|
setRemountKey((k) => k + 1);
|
|
}, [onRemount]);
|
|
|
|
const remount = useCallback(() => {
|
|
triggerRemount("manual");
|
|
}, [triggerRemount]);
|
|
|
|
useEffect(() => {
|
|
focusedRef.current = isFocused;
|
|
if (!isFocused) {
|
|
lastBlurAtRef.current = Date.now();
|
|
}
|
|
}, [isFocused]);
|
|
|
|
const shouldIgnoreProcessTermination = useCallback(() => {
|
|
if (!ignoreProcessTerminationWhenUnfocused) return false;
|
|
if (!focusedRef.current) return true;
|
|
if (lastBlurAtRef.current === null) return false;
|
|
return Date.now() - lastBlurAtRef.current < processTerminationBlurGraceMs;
|
|
}, [ignoreProcessTerminationWhenUnfocused]);
|
|
|
|
// 明示指定がある場合のみ、長時間バックグラウンド復帰で再マウントする。
|
|
useEffect(() => {
|
|
if (backgroundThresholdMs === null || backgroundThresholdMs <= 0) {
|
|
return;
|
|
}
|
|
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) {
|
|
triggerRemount("app_background", { elapsedMs: elapsed });
|
|
}
|
|
}
|
|
};
|
|
const subscription = AppState.addEventListener("change", onAppStateChange);
|
|
return () => subscription.remove();
|
|
}, [backgroundThresholdMs, triggerRemount]);
|
|
|
|
// 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(() => {
|
|
if (pauseWatchdogWhenUnfocused && !isFocused) {
|
|
lastPongAt.current = Date.now();
|
|
return;
|
|
}
|
|
const elapsed = Date.now() - lastPongAt.current;
|
|
if (isLoadingRef.current) {
|
|
// ローディング中でも45秒超はレンダラー死亡と判定
|
|
if (elapsed > 45_000) triggerRemount("loading_timeout", { elapsedMs: elapsed });
|
|
return;
|
|
}
|
|
// ロード完了後30秒 pong 無応答 → レンダラー死亡
|
|
if (elapsed > 30_000) {
|
|
triggerRemount("pong_timeout", { elapsedMs: elapsed });
|
|
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);
|
|
}, [isFocused, pauseWatchdogWhenUnfocused, pingEnabled, triggerRemount]);
|
|
|
|
const processHandlers = {
|
|
onRenderProcessGone: (event?: any) => {
|
|
if (shouldIgnoreProcessTermination()) {
|
|
ignoredProcessTerminationRef.current = "render_process_gone";
|
|
lastPongAt.current = Date.now();
|
|
return;
|
|
}
|
|
triggerRemount("render_process_gone", { didCrash: event?.nativeEvent?.didCrash ?? null });
|
|
},
|
|
onContentProcessDidTerminate: () => {
|
|
if (shouldIgnoreProcessTermination()) {
|
|
ignoredProcessTerminationRef.current = "content_process_terminated";
|
|
lastPongAt.current = Date.now();
|
|
return;
|
|
}
|
|
triggerRemount("content_process_terminated");
|
|
},
|
|
} as const;
|
|
|
|
useEffect(() => {
|
|
if (!isFocused || !ignoredProcessTerminationRef.current) return;
|
|
ignoredProcessTerminationRef.current = null;
|
|
lastPongAt.current = Date.now();
|
|
}, [isFocused]);
|
|
|
|
// 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) {
|
|
const blankCount = blankCountRef.current;
|
|
const maxTextLength = maxTextLenRef.current;
|
|
blankCountRef.current = 0;
|
|
maxTextLenRef.current = 0;
|
|
triggerRemount("blank_detected", {
|
|
blankCount,
|
|
textLength: len,
|
|
maxTextLength,
|
|
});
|
|
}
|
|
} else {
|
|
blankCountRef.current = 0;
|
|
}
|
|
}
|
|
} catch {}
|
|
},
|
|
} : {};
|
|
|
|
return { remountKey, remount, processHandlers, pingHandlers, webViewRef };
|
|
}
|