diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index 78afe9af2..8ddecd15f 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -1379,6 +1379,8 @@ for (const snippet of [ 'injectLifecycleEvent', 'injectNetworkStatusEvent', 'logMobileHostEventFailure', + 'try {', + 'logMobileHostEventFailure(event, error)', "logMobileHostEventFailure('network.statusChanged', error)", 'handleWebViewLoad', 'onLoad={handleWebViewLoad}', @@ -2435,6 +2437,9 @@ for (const snippet of [ "lastHostBridgeEvent('navigation.canGoBack')", "shellHarness.appStateListeners[0]?.('background')", 'shellHarness.networkListeners[0]?.({', + 'injectJavaScriptError', + "test('host event injection failures are logged without crashing the shell'", + "new Error('webview injection failed')", "type: 'genarrative.mobile.historyState'", 'mobile host event failed for network.statusChanged', 'external WebView navigation native failures stay outside the WebView', diff --git a/apps/mobile-shell/src/shell/ShellApp.test.tsx b/apps/mobile-shell/src/shell/ShellApp.test.tsx index 158157122..443da57da 100644 --- a/apps/mobile-shell/src/shell/ShellApp.test.tsx +++ b/apps/mobile-shell/src/shell/ShellApp.test.tsx @@ -15,18 +15,21 @@ const shellHarness = vi.hoisted(() => { const appStateListeners = [] as Array<(state: string) => void>; const webViewProps = { current: null as Record | null }; const cameraViewProps = { current: null as Record | null }; + const injectJavaScriptError = { current: null as Error | null }; const injectedScripts = [] as string[]; const networkListeners = [] as Array<(state: Record) => void>; return { appStateListeners, cameraViewProps, + injectJavaScriptError, injectedScripts, networkListeners, reset() { appStateListeners.length = 0; webViewProps.current = null; cameraViewProps.current = null; + injectJavaScriptError.current = null; injectedScripts.length = 0; networkListeners.length = 0; }, @@ -158,6 +161,9 @@ vi.mock('react-native-webview', () => ({ React.useImperativeHandle(ref, () => ({ goBack: vi.fn(), injectJavaScript: (script: string) => { + if (shellHarness.injectJavaScriptError.current) { + throw shellHarness.injectJavaScriptError.current; + } shellHarness.injectedScripts.push(script); }, reload: vi.fn(), @@ -372,6 +378,34 @@ describe('ShellApp HostBridge event injection', () => { }); }); + test('host event injection failures are logged without crashing the shell', async () => { + const ShellApp = await importShellApp(); + render(); + + await waitFor(() => { + expect(shellHarness.networkListeners.length).toBeGreaterThan(0); + }); + + const injectionError = new Error('webview injection failed'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + shellHarness.injectJavaScriptError.current = injectionError; + + expect(() => { + shellHarness.networkListeners[0]?.({ + isConnected: false, + isInternetReachable: false, + type: 'NONE', + }); + }).not.toThrow(); + + expect(warnSpy).toHaveBeenCalledWith( + 'mobile host event failed for network.statusChanged', + injectionError, + ); + + warnSpy.mockRestore(); + }); + test('native and H5 navigation state inject combined navigation.canGoBack events', async () => { const ShellApp = await importShellApp(); render(); diff --git a/apps/mobile-shell/src/shell/ShellApp.tsx b/apps/mobile-shell/src/shell/ShellApp.tsx index 950d17095..7f6e4174c 100644 --- a/apps/mobile-shell/src/shell/ShellApp.tsx +++ b/apps/mobile-shell/src/shell/ShellApp.tsx @@ -124,9 +124,13 @@ export default function ShellApp() { }, []); const injectHostBridgeEvent = useCallback( (event: HostBridgeEventName, payload: unknown) => { - webViewRef.current?.injectJavaScript( - buildHostBridgeEventScript(event, payload), - ); + try { + webViewRef.current?.injectJavaScript( + buildHostBridgeEventScript(event, payload), + ); + } catch (error) { + logMobileHostEventFailure(event, error); + } }, [], ); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f9cbbfc73..3ef6d337f 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-06-20 移动 HostBridge 事件注入失败边界 + +- 背景:Expo 移动壳通过 WebView `injectJavaScript` 把 `app.lifecycle`、`network.statusChanged`、`navigation.canGoBack` 等宿主事件回放给 H5;如果 WebView 进程切换、页面卸载或注入同步失败,壳层不能因为宿主事件回放异常而崩溃。 +- 决策:移动壳所有 HostBridge 事件注入必须统一经过 `injectHostBridgeEvent`,该函数捕获同步注入异常并用 `logMobileHostEventFailure(event, error)` 记录;配置检查反查运行时 try/catch 和 ShellApp 注入失败测试。 +- 影响范围:`apps/mobile-shell/src/shell/ShellApp.tsx`、`apps/mobile-shell/src/shell/ShellApp.test.tsx`、`apps/mobile-shell/scripts/check-config.mjs`。 +- 验证方式:`npm run mobile-shell:test -- src/shell/ShellApp.test.tsx`、`npm run mobile-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 +- 关联文档:`docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md`、`docs/【前端架构】宿主壳能力统一协议-2026-06-17.md`。 + ## 2026-06-20 桌面图片拖拽坐标非负边界 - 背景:Tauri 桌面壳通过系统拖拽事件向 H5 发送 `file.imageDropped`,拖拽坐标来自窗口事件;窗口边缘或平台差异可能产生负数或小数坐标,H5 只负责校验有限 number,不负责裁剪桌面系统坐标。