补齐移动壳进程崩溃恢复边界

移动壳 WebView 进程失败增加防循环恢复

加载失败面板承接连续进程恢复失败

配置检查和文档同步崩溃恢复约束
This commit is contained in:
2026-06-20 19:07:12 +08:00
parent fc9a557978
commit c5d899480f
7 changed files with 206 additions and 11 deletions
+77 -2
View File
@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import { render, waitFor } from '@testing-library/react';
import { act, render, waitFor } from '@testing-library/react';
import * as Network from 'expo-network';
import React from 'react';
import { afterEach, describe, expect, test, vi } from 'vitest';
@@ -19,6 +19,7 @@ const shellHarness = vi.hoisted(() => {
const injectedScripts = [] as string[];
const linkingUrlListeners = [] as Array<(event: { url: string }) => void>;
const networkListeners = [] as Array<(state: Record<string, unknown>) => void>;
const reloadWebView = vi.fn();
return {
appStateListeners,
@@ -34,8 +35,10 @@ const shellHarness = vi.hoisted(() => {
injectedScripts.length = 0;
linkingUrlListeners.length = 0;
networkListeners.length = 0;
reloadWebView.mockClear();
},
linkingUrlListeners,
reloadWebView,
webViewProps,
};
});
@@ -169,7 +172,7 @@ vi.mock('react-native-webview', () => ({
}
shellHarness.injectedScripts.push(script);
},
reload: vi.fn(),
reload: shellHarness.reloadWebView,
}));
return React.createElement('mobile-web-view');
}),
@@ -673,4 +676,76 @@ describe('ShellApp HostBridge event injection', () => {
warnSpy.mockRestore();
});
test('first WebView process failure reloads the current page once', async () => {
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1_000);
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const ShellApp = await importShellApp();
render(<ShellApp />);
const { Linking } = await import('react-native');
await waitFor(() => {
expect(Linking.getInitialURL).toHaveBeenCalled();
});
const webViewProps = shellHarness.webViewProps.current as {
onContentProcessDidTerminate?: () => void;
};
act(() => {
webViewProps.onContentProcessDidTerminate?.();
});
expect(shellHarness.reloadWebView).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalledWith('mobile WebView process failed', {
count: 1,
label: 'content_process_terminated',
url: expect.stringContaining('https://app.genarrative.world/'),
});
warnSpy.mockRestore();
nowSpy.mockRestore();
});
test('repeated WebView process failures show the load failure panel and retry clears the failure window', async () => {
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(2_000);
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const ShellApp = await importShellApp();
const screen = render(<ShellApp />);
const { Linking } = await import('react-native');
await waitFor(() => {
expect(Linking.getInitialURL).toHaveBeenCalled();
});
const webViewProps = shellHarness.webViewProps.current as {
onRenderProcessGone?: () => void;
};
act(() => {
webViewProps.onRenderProcessGone?.();
webViewProps.onRenderProcessGone?.();
});
await waitFor(() => {
expect(screen.getByText('页面已停止')).toBeTruthy();
});
expect(shellHarness.reloadWebView).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenLastCalledWith('mobile WebView process failed', {
count: 2,
label: 'render_process_gone',
url: expect.stringContaining('https://app.genarrative.world/'),
});
act(() => {
screen.getByText('重试').click();
});
expect(shellHarness.reloadWebView).toHaveBeenCalledTimes(2);
act(() => {
webViewProps.onRenderProcessGone?.();
});
expect(shellHarness.reloadWebView).toHaveBeenCalledTimes(3);
warnSpy.mockRestore();
nowSpy.mockRestore();
});
});
+76 -4
View File
@@ -75,6 +75,9 @@ function logMobileShellDeepLinkFailure(label: string, error: unknown) {
console.warn(`mobile shell deep link failed for ${label}`, error);
}
const MOBILE_WEBVIEW_PROCESS_FAILURE_WINDOW_MS = 30_000;
const MOBILE_WEBVIEW_PROCESS_FAILURE_RELOAD_LIMIT = 1;
type MobileWebViewLoadErrorEvent = {
nativeEvent: {
url: string;
@@ -96,6 +99,10 @@ export default function ShellApp() {
const isShellMountedRef = useRef(true);
const nativeCanGoBackRef = useRef(false);
const h5CanGoBackRef = useRef(false);
const webViewProcessFailureRef = useRef({
count: 0,
firstFailureAt: 0,
});
const [canGoBack, setCanGoBack] = useState(false);
const baseWebUrl = resolveMobileShellBaseWebUrl(
process.env.EXPO_PUBLIC_GENARRATIVE_WEB_URL,
@@ -117,6 +124,12 @@ export default function ShellApp() {
const reloadCurrentWebView = useCallback(() => {
webViewRef.current?.reload();
}, []);
const resetWebViewProcessFailureWindow = useCallback(() => {
webViewProcessFailureRef.current = {
count: 0,
firstFailureAt: 0,
};
}, []);
useEffect(() => {
isShellMountedRef.current = true;
@@ -197,6 +210,7 @@ export default function ShellApp() {
allowedOrigin: allowedWebOrigin,
urlOptions,
openWebViewUrl(url) {
resetWebViewProcessFailureWindow();
resetNavigationCanGoBack();
setLoadFailure(null);
setWebUrl(url);
@@ -205,7 +219,13 @@ export default function ShellApp() {
});
return () => configureMobileHostBridgeNavigation(null);
}, [allowedWebOrigin, reloadCurrentWebView, resetNavigationCanGoBack, urlOptions]);
}, [
allowedWebOrigin,
reloadCurrentWebView,
resetNavigationCanGoBack,
resetWebViewProcessFailureWindow,
urlOptions,
]);
useEffect(() => {
let disposed = false;
@@ -222,6 +242,7 @@ export default function ShellApp() {
if (resolution.status === 'rejected') {
logMobileShellDeepLinkFailure(`${source}.rejected`, url);
}
resetWebViewProcessFailureWindow();
resetNavigationCanGoBack();
setLoadFailure(null);
setWebUrl(resolution.url);
@@ -249,7 +270,12 @@ export default function ShellApp() {
disposed = true;
subscription.remove();
};
}, [baseWebUrl, resetNavigationCanGoBack, urlOptions]);
}, [
baseWebUrl,
resetNavigationCanGoBack,
resetWebViewProcessFailureWindow,
urlOptions,
]);
useEffect(() => {
const subscription = BackHandler.addEventListener(
@@ -336,6 +362,7 @@ export default function ShellApp() {
}
setLoadFailure(null);
resetWebViewProcessFailureWindow();
injectLifecycleEvent(AppState.currentState);
void getMobileNetworkStatus()
.then(injectNetworkStatusEvent)
@@ -375,8 +402,49 @@ export default function ShellApp() {
};
const handleRetryLoadFailure = () => {
setLoadFailure(null);
resetWebViewProcessFailureWindow();
reloadCurrentWebView();
};
const handleWebViewProcessFailure = (label: string) => {
const now = Date.now();
const current = webViewProcessFailureRef.current;
const isWithinFailureWindow =
current.firstFailureAt > 0 &&
now - current.firstFailureAt <= MOBILE_WEBVIEW_PROCESS_FAILURE_WINDOW_MS;
const nextFailureWindow = isWithinFailureWindow
? {
count: current.count + 1,
firstFailureAt: current.firstFailureAt,
}
: {
count: 1,
firstFailureAt: now,
};
webViewProcessFailureRef.current = nextFailureWindow;
console.warn('mobile WebView process failed', {
count: nextFailureWindow.count,
label,
url: webUrl,
});
if (nextFailureWindow.count <= MOBILE_WEBVIEW_PROCESS_FAILURE_RELOAD_LIMIT) {
reloadCurrentWebView();
return;
}
resetNavigationCanGoBack();
setLoadFailure(
normalizeMobileShellLoadFailure(
{
type: 'process',
url: webUrl,
description: 'WebView renderer terminated repeatedly',
},
allowedWebOrigin,
webUrl,
),
);
};
const handleBlockedFileDownload = () => undefined;
return (
@@ -404,8 +472,12 @@ export default function ShellApp() {
injectedJavaScriptBeforeContentLoaded={MOBILE_WEBVIEW_BEFORE_CONTENT_SCRIPT}
onFileDownload={handleBlockedFileDownload}
onMessage={handleMessage}
onContentProcessDidTerminate={reloadCurrentWebView}
onRenderProcessGone={reloadCurrentWebView}
onContentProcessDidTerminate={() => {
handleWebViewProcessFailure('content_process_terminated');
}}
onRenderProcessGone={() => {
handleWebViewProcessFailure('render_process_gone');
}}
onLoad={handleWebViewLoad}
onError={handleWebViewLoadError}
onHttpError={handleWebViewHttpError}
@@ -112,4 +112,24 @@ describe('loadFailure', () => {
)?.title,
).toBe('加载失败 503');
});
test('连续 WebView 进程恢复失败时展示同源页面兜底', () => {
expect(
normalizeMobileShellLoadFailure(
{
type: 'process',
url: 'https://app.genarrative.world/works/detail?work=WF-1',
description: 'WebView renderer terminated repeatedly',
},
allowedOrigin,
'https://app.genarrative.world/works/detail?work=WF-1',
),
).toEqual({
type: 'process',
url: 'https://app.genarrative.world/works/detail?work=WF-1',
title: '页面已停止',
detail: 'WebView renderer terminated repeatedly',
retryLabel: '重试',
});
});
});
+16 -1
View File
@@ -12,10 +12,15 @@ export type MobileShellLoadFailureInput =
url: string;
statusCode?: number | null;
description?: string | null;
}
| {
type: 'process';
url: string;
description?: string | null;
};
export type MobileShellLoadFailure = {
type: 'native' | 'http';
type: 'native' | 'http' | 'process';
url: string;
title: string;
detail: string;
@@ -100,6 +105,16 @@ export function normalizeMobileShellLoadFailure(
};
}
if (input.type === 'process') {
return {
type: 'process',
url,
title: '页面已停止',
detail: description ?? '当前页面连续恢复失败',
retryLabel: '重试',
};
}
return {
type: 'native',
url,