From e892113060fb226e436573d0031ea89b0e7e7bc5 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 20 Jun 2026 16:02:47 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E7=B4=A7=E7=A7=BB=E5=8A=A8=E5=A3=B3?= =?UTF-8?q?=E8=83=BD=E5=8A=9B=E5=A3=B0=E6=98=8E=E4=B8=8E=E8=BF=9F=E5=88=B0?= =?UTF-8?q?=E5=93=8D=E5=BA=94=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移动壳卸载后丢弃迟到 HostBridge 响应 移动壳 ShellApp 测试覆盖卸载后异步响应不注入 移动壳配置检查拒绝只返回 unsupported 的声明能力 共享决策记录补充移动壳消息注入和能力声明约定 --- apps/mobile-shell/scripts/check-config.mjs | 36 +++++++++++++ apps/mobile-shell/src/shell/ShellApp.test.tsx | 54 +++++++++++++++++++ apps/mobile-shell/src/shell/ShellApp.tsx | 12 +++++ .../shared-memory/decision-log.md | 2 +- 4 files changed, 103 insertions(+), 1 deletion(-) diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index 53b629cf1..5ca3bab41 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -390,6 +390,26 @@ function extractMobileBridgeHandledMethods(source) { return [...match[1].matchAll(/case '([^']+)':/g)].map((entry) => entry[1]); } +function extractMobileBridgeUnsupportedMethods(source) { + const match = source.match( + /async function dispatchMobileHostBridgeRequest[\s\S]*?switch \(request\.method\) \{([\s\S]*?)\n \}/, + ); + if (!match) { + throw new Error('unable to read mobile shell HostBridge unsupported methods'); + } + + const unsupportedMethods = new Set(); + const casePattern = + /case '([^']+)':([\s\S]*?)(?=\n case '|\n default:|\n \})/g; + for (const entry of match[1].matchAll(casePattern)) { + if (entry[2].includes('unsupported(request.method)')) { + unsupportedMethods.add(entry[1]); + } + } + + return [...unsupportedMethods]; +} + function extractFunctionBody(source, functionName) { const start = source.indexOf(`function ${functionName}`); if (start === -1) { @@ -821,6 +841,7 @@ if (sharedPublicWebOriginUrl.protocol !== 'https:') { const sharedPublicWebHost = sharedPublicWebOriginUrl.hostname; const sharedPublicWebAssociatedDomain = `applinks:${sharedPublicWebHost}`; const handledMobileMethods = extractMobileBridgeHandledMethods(dispatchSource); +const unsupportedMobileMethods = extractMobileBridgeUnsupportedMethods(dispatchSource); const mobileCapabilities = sharedMobileBaseCapabilities; const iosMobileCapabilities = sharedMobileIosCapabilities; const mobileCapabilitySet = new Set(mobileCapabilities); @@ -1054,6 +1075,17 @@ if (missingMobileMethodHandlers.length > 0) { ); } +const unsupportedMobileCapabilities = iosMobileCapabilities.filter( + (capability) => + sharedMethods.includes(capability) && + unsupportedMobileMethods.includes(capability), +); +if (unsupportedMobileCapabilities.length > 0) { + throw new Error( + `mobile shell declares request capabilities backed only by unsupported responses: ${unsupportedMobileCapabilities.join(', ')}`, + ); +} + const undeclaredMobileMethodHandlers = handledMobileMethods.filter( (method) => !iosMobileCapabilitySet.has(method) && !sdkBackedCapabilities.includes(method), @@ -1375,6 +1407,7 @@ for (const snippet of [ 'h5CanGoBackRef', 'syncNavigationCanGoBack', 'resetNavigationCanGoBack', + 'isShellMountedRef', 'injectHostBridgeMessage', 'injectHostBridgeEvent', 'injectLifecycleEvent', @@ -1382,6 +1415,7 @@ for (const snippet of [ 'logMobileHostEventFailure', 'logMobileHostBridgeMessageFailure', 'try {', + 'if (!isShellMountedRef.current)', 'logMobileHostEventFailure(event, error)', 'injectHostBridgeMessage(response, logMobileHostBridgeMessageFailure)', "console.warn('mobile HostBridge message injection failed', error)", @@ -2451,6 +2485,8 @@ for (const snippet of [ "test('logs HostBridge response injection failures without crashing the shell'", "new Error('response injection failed')", 'mobile HostBridge message injection failed', + "test('drops delayed HostBridge responses after shell unmount'", + "id: 'network-request-1'", "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 47e01ffc3..917394086 100644 --- a/apps/mobile-shell/src/shell/ShellApp.test.tsx +++ b/apps/mobile-shell/src/shell/ShellApp.test.tsx @@ -368,6 +368,60 @@ describe('ShellApp QR scanner HostBridge flow', () => { warnSpy.mockRestore(); }); + + test('drops delayed HostBridge responses after shell unmount', async () => { + const ShellApp = await importShellApp(); + let resolveNetworkStatus: + (state: Awaited>) => void = + () => { + throw new Error('network status resolver missing'); + }; + vi.mocked(Network.getNetworkStateAsync).mockReturnValueOnce( + new Promise((resolve) => { + resolveNetworkStatus = resolve; + }), + ); + const { unmount } = render(); + + const webViewProps = shellHarness.webViewProps.current as { + onMessage?: (event: { + nativeEvent: { + data: string; + url: string; + }; + }) => void; + source?: { uri?: string }; + }; + const webViewUrl = webViewProps.source?.uri ?? 'https://app.genarrative.world/'; + + webViewProps.onMessage?.({ + nativeEvent: { + data: JSON.stringify({ + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: 'network-request-1', + method: 'network.status', + }), + url: webViewUrl, + }, + }); + unmount(); + + resolveNetworkStatus({ + isConnected: true, + isInternetReachable: true, + type: Network.NetworkStateType.WIFI, + }); + + await waitFor(() => { + expect(Network.getNetworkStateAsync).toHaveBeenCalled(); + }); + expect( + shellHarness.injectedScripts.some((script) => + script.includes('network-request-1'), + ), + ).toBe(false); + }); }); describe('ShellApp HostBridge event injection', () => { diff --git a/apps/mobile-shell/src/shell/ShellApp.tsx b/apps/mobile-shell/src/shell/ShellApp.tsx index 2acb15c8c..dcfb5813a 100644 --- a/apps/mobile-shell/src/shell/ShellApp.tsx +++ b/apps/mobile-shell/src/shell/ShellApp.tsx @@ -89,6 +89,7 @@ type MobileWebViewHttpErrorEvent = { export default function ShellApp() { const webViewRef = useRef(null); + const isShellMountedRef = useRef(true); const nativeCanGoBackRef = useRef(false); const h5CanGoBackRef = useRef(false); const [canGoBack, setCanGoBack] = useState(false); @@ -112,11 +113,22 @@ export default function ShellApp() { const reloadCurrentWebView = useCallback(() => { webViewRef.current?.reload(); }, []); + useEffect(() => { + isShellMountedRef.current = true; + + return () => { + isShellMountedRef.current = false; + }; + }, []); const injectHostBridgeMessage = useCallback( ( message: unknown, onError: (error: unknown) => void, ) => { + if (!isShellMountedRef.current) { + return; + } + try { webViewRef.current?.injectJavaScript( buildHostBridgeMessageScript(message), diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 66aa973d7..b26c59e72 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -19,7 +19,7 @@ ## 2026-06-20 移动 HostBridge 消息注入失败边界 - 背景:Expo 移动壳通过 WebView `injectJavaScript` 把 HostBridge response 以及 `app.lifecycle`、`network.statusChanged`、`navigation.canGoBack` 等宿主事件回放给 H5;如果 WebView 进程切换、页面卸载或注入同步失败,壳层不能因为响应或事件回灌异常而崩溃。 -- 决策:移动壳所有 HostBridge message 注入必须统一经过 `injectHostBridgeMessage`,该函数捕获同步注入异常;事件注入用 `logMobileHostEventFailure(event, error)` 记录,response 注入用 `logMobileHostBridgeMessageFailure(error)` 记录。配置检查反查运行时 try/catch 和 ShellApp 注入失败测试。 +- 决策:移动壳所有 HostBridge message 注入必须统一经过 `injectHostBridgeMessage`,该函数捕获同步注入异常,且 shell 卸载后直接丢弃迟到 response;事件注入用 `logMobileHostEventFailure(event, error)` 记录,response 注入用 `logMobileHostBridgeMessageFailure(error)` 记录。移动壳声明的请求 capability 不允许只由 `unsupported(request.method)` case 支撑;配置检查反查运行时 mounted guard、try/catch、ShellApp 注入失败测试、卸载后迟到响应测试和 capability 真实实现边界。 - 影响范围:`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`。