From c5d899480f76d84294178edc4561ae1bb0c36ef5 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 20 Jun 2026 19:07:12 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E7=A7=BB=E5=8A=A8=E5=A3=B3?= =?UTF-8?q?=E8=BF=9B=E7=A8=8B=E5=B4=A9=E6=BA=83=E6=81=A2=E5=A4=8D=E8=BE=B9?= =?UTF-8?q?=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移动壳 WebView 进程失败增加防循环恢复 加载失败面板承接连续进程恢复失败 配置检查和文档同步崩溃恢复约束 --- apps/mobile-shell/scripts/check-config.mjs | 17 +++- apps/mobile-shell/src/shell/ShellApp.test.tsx | 79 +++++++++++++++++- apps/mobile-shell/src/shell/ShellApp.tsx | 80 ++++++++++++++++++- .../src/shell/loadFailure.test.ts | 20 +++++ apps/mobile-shell/src/shell/loadFailure.ts | 17 +++- .../shared-memory/decision-log.md | 2 +- ...ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 2 +- 7 files changed, 206 insertions(+), 11 deletions(-) diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index 18f7b21b9..3529b07be 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -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)) { diff --git a/apps/mobile-shell/src/shell/ShellApp.test.tsx b/apps/mobile-shell/src/shell/ShellApp.test.tsx index ec822bf38..4d55bc739 100644 --- a/apps/mobile-shell/src/shell/ShellApp.test.tsx +++ b/apps/mobile-shell/src/shell/ShellApp.test.tsx @@ -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) => 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(); + 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(); + 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(); + }); }); diff --git a/apps/mobile-shell/src/shell/ShellApp.tsx b/apps/mobile-shell/src/shell/ShellApp.tsx index 0ecd53663..563cf0cc3 100644 --- a/apps/mobile-shell/src/shell/ShellApp.tsx +++ b/apps/mobile-shell/src/shell/ShellApp.tsx @@ -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} diff --git a/apps/mobile-shell/src/shell/loadFailure.test.ts b/apps/mobile-shell/src/shell/loadFailure.test.ts index d4f417243..bc89216aa 100644 --- a/apps/mobile-shell/src/shell/loadFailure.test.ts +++ b/apps/mobile-shell/src/shell/loadFailure.test.ts @@ -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: '重试', + }); + }); }); diff --git a/apps/mobile-shell/src/shell/loadFailure.ts b/apps/mobile-shell/src/shell/loadFailure.ts index 1a319e742..35861de3b 100644 --- a/apps/mobile-shell/src/shell/loadFailure.ts +++ b/apps/mobile-shell/src/shell/loadFailure.ts @@ -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, diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 138ad55ee..08ecd86a6 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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`。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 86b470d9b..4d2fddecb 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -302,7 +302,7 @@ GameBridge 禁止: - iOS / Android 深链打开作品详情、创作页和邀请码。 - 登录和支付先 fallback 到 H5;只把能力边界跑通。 -当前状态:已新增 `apps/mobile-shell/`,通过 Expo development build 运行,`react-native-webview` 加载 H5 URL 并附加 `native_app` 宿主 query。移动壳使用真实品牌图标资产,已接入 `genarrative://` scheme、iOS associated domain 和 Android app link filter,启动和运行时 deep link 只会映射到同源 H5 路径并继续附加 HostBridge 上下文,外域和危险协议回退到默认主站入口并记录拒绝日志;系统初始 URL 读取失败也会记录错误且保留当前安全入口。首轮真实能力包括 `host.getRuntime`、`appearance.getColorScheme`、`host.events`、`app.lifecycle`、`network.status`、`network.statusChanged`、`share.open`、`share.setTarget`、`navigation.openNativePage`、`navigation.canGoBack`、`app.reloadWebView`、`app.openExternalUrl`、`clipboard.writeText`、`clipboard.readText`、`file.exportText`、`file.importText`、`file.importDocument`、`file.exportImage`、`file.importImage`、`file.captureImage`、`scanner.scanQrCode`、`file.importAudio`、`file.exportAudio`、`haptics.impact`、`notification.showLocal` 和 Android 返回键回退;其中 `appearance.getColorScheme` 只读系统配色偏好,不强改 H5 或系统主题;`app.lifecycle` 通过 React Native `AppState` 注入 `active` / `inactive` / `background` 统一状态,供 H5 游戏循环、音频和轮询做真实暂停 / 恢复判断,H5 的 `useHostLifecycleActive()` 会把该事件归一成运行态可播放状态,WebAudio 背景音乐和拼图、抓大鹅等固定玩法 `