Files
jrshikoku/lib/useWebViewRemount.ts

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