补齐移动壳进程崩溃恢复边界
移动壳 WebView 进程失败增加防循环恢复 加载失败面板承接连续进程恢复失败 配置检查和文档同步崩溃恢复约束
This commit is contained in:
@@ -1396,8 +1396,12 @@ for (const snippet of [
|
||||
'webViewRef.current?.reload()',
|
||||
'const reloadCurrentWebView = useCallback(() => {',
|
||||
'reloadWebView: reloadCurrentWebView',
|
||||
'onContentProcessDidTerminate={reloadCurrentWebView}',
|
||||
'onRenderProcessGone={reloadCurrentWebView}',
|
||||
'MOBILE_WEBVIEW_PROCESS_FAILURE_WINDOW_MS',
|
||||
'MOBILE_WEBVIEW_PROCESS_FAILURE_RELOAD_LIMIT',
|
||||
'handleWebViewProcessFailure',
|
||||
"console.warn('mobile WebView process failed'",
|
||||
"handleWebViewProcessFailure('content_process_terminated')",
|
||||
"handleWebViewProcessFailure('render_process_gone')",
|
||||
'normalizeMobileShellLoadFailure',
|
||||
'handleWebViewLoadError',
|
||||
'handleWebViewHttpError',
|
||||
@@ -1558,6 +1562,8 @@ for (const snippet of [
|
||||
'sameDocumentUrl',
|
||||
'shouldOpenInMobileShellWebView',
|
||||
"input.type === 'http'",
|
||||
"input.type === 'process'",
|
||||
"title: '页面已停止'",
|
||||
"title: '网络不可用'",
|
||||
"retryLabel: '重试'",
|
||||
"url.pathname !== '/favicon.ico'",
|
||||
@@ -1572,6 +1578,9 @@ for (const snippet of [
|
||||
"test('归一化同源 HTTP 加载失败'",
|
||||
"test('忽略外域和非页面加载失败'",
|
||||
"test('只展示当前主页面失败'",
|
||||
"test('连续 WebView 进程恢复失败时展示同源页面兜底'",
|
||||
"type: 'process'",
|
||||
"title: '页面已停止'",
|
||||
"url: 'https://example.com/'",
|
||||
"url: 'about:blank'",
|
||||
"url: 'javascript:alert(1)'",
|
||||
@@ -2509,6 +2518,10 @@ for (const snippet of [
|
||||
"'mobile shell deep link failed for initial_url.read'",
|
||||
'runtime deep link rejections are logged and fall back to a safe WebView URL',
|
||||
"'mobile shell deep link failed for runtime_url.rejected'",
|
||||
'first WebView process failure reloads the current page once',
|
||||
'mobile WebView process failed',
|
||||
'repeated WebView process failures show the load failure panel and retry clears the failure window',
|
||||
"expect(screen.getByText('页面已停止')).toBeTruthy()",
|
||||
'expect.any(Error)',
|
||||
]) {
|
||||
if (!shellAppTestSource.includes(snippet)) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: '重试',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2438,7 +2438,7 @@
|
||||
- 决策:新增 HostBridge method `app.reloadWebView` 和 H5 facade `reloadHostWebView()`。移动端只调用当前 `react-native-webview` 的 `reload()`,桌面端只调用 Tauri 主 `WebviewWindow.reload()`;该 method 不接受 payload,成功只表示宿主已发起刷新,刷新后当前 H5 上下文会卸载。继续把同源跳转留给 `navigation.openNativePage`,外链离开容器留给 `app.openExternalUrl`。
|
||||
- 2026-06-18 追加:Expo 移动壳的外链离开容器路径必须在协议白名单后再调用 `Linking.canOpenURL`;WebView 外域导航只有当前设备确认可打开时才调用 `Linking.openURL`,`app.openExternalUrl` 在系统不可打开时返回 `host_error`。该收紧不新增 HostBridge method,不把危险协议、相对路径或设备不可处理的外链留在带完整 HostBridge 的 WebView 内。
|
||||
- 2026-06-18 追加:`AuthGate` 登录态身份边界刷新改为优先调用 `reloadHostWebView()`,用于登录成功、退出登录或从已登录变为未登录后的主站重新初始化;宿主未声明、返回失败或不可用时再回退浏览器 `window.location.reload()`,普通 token refresh、账号资料更新、主题和音量变化仍不触发整页刷新。
|
||||
- 2026-06-18 追加:Expo 移动壳的 iOS `onContentProcessDidTerminate` 和 Android `onRenderProcessGone` 也复用当前 WebView 的受控 `reload()` 路径,系统回收 WebView 内容 / 渲染进程后只恢复同一 H5 容器,不改写 URL、不注入额外脚本、不新增宿主恢复页面。
|
||||
- 2026-06-20 追加:Expo 移动壳的 iOS `onContentProcessDidTerminate` 和 Android `onRenderProcessGone` 首次触发时复用当前 WebView 的受控 `reload()` 路径;短时间内连续进程恢复失败必须记录 `mobile WebView process failed` 日志,并转入既有原生加载失败兜底层。用户重试会清空进程失败窗口并再次刷新当前 WebView;全程不改写 URL、不注入额外脚本、不新增宿主恢复页面。
|
||||
- 2026-06-18 追加:Expo 移动壳的 `onError` / `onHttpError` 只对同源 H5 主页面展示原生加载失败兜底层,用户重试时仍复用当前 WebView `reload()`;兜底不接管外域、危险协议、`about:blank` 或 favicon 失败,也不向 H5 注入错误事件。
|
||||
- 影响范围:`packages/shared/src/contracts/hostBridge.ts`、`src/services/host-bridge/hostBridge.ts`、`apps/mobile-shell/`、`apps/desktop-shell/`、原生壳能力检查脚本和 HostBridge 架构文档。
|
||||
- 验证方式:`npm run check:native-shells`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user