收紧移动壳能力声明与迟到响应边界
移动壳卸载后丢弃迟到 HostBridge 响应 移动壳 ShellApp 测试覆盖卸载后异步响应不注入 移动壳配置检查拒绝只返回 unsupported 的声明能力 共享决策记录补充移动壳消息注入和能力声明约定
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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<ReturnType<typeof Network.getNetworkStateAsync>>) => void =
|
||||
() => {
|
||||
throw new Error('network status resolver missing');
|
||||
};
|
||||
vi.mocked(Network.getNetworkStateAsync).mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveNetworkStatus = resolve;
|
||||
}),
|
||||
);
|
||||
const { unmount } = render(<ShellApp />);
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -89,6 +89,7 @@ type MobileWebViewHttpErrorEvent = {
|
||||
|
||||
export default function ShellApp() {
|
||||
const webViewRef = useRef<WebView>(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),
|
||||
|
||||
@@ -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`。
|
||||
|
||||
Reference in New Issue
Block a user