接入原生返回栈状态消费

H5 新增 navigation.canGoBack hook 并在直达二级页时补齐返回锚点

路由导航保留完整原生宿主上下文并标记应用历史状态

补齐 HostBridge 返回栈消费测试、门禁和文档
This commit is contained in:
2026-06-19 10:59:47 +08:00
parent 4a16263729
commit ce0765b298
13 changed files with 364 additions and 5 deletions
+40 -1
View File
@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import { act, fireEvent, render, screen } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, test, vi } from 'vitest';
import App from './App';
@@ -28,8 +28,10 @@ vi.mock('./components/platform-entry/PlatformEntryFlowShell', () => ({
PlatformEntryFlowShell: ({
handleCustomWorldSelect,
setSelectionStage,
selectionStage,
}: PlatformEntryFlowShellProps) => (
<div>
<div data-testid="selection-stage">{selectionStage}</div>
<div data-testid="share-capability">
{canUseNativeHostCapability('share.open') ? 'enabled' : 'disabled'}
</div>
@@ -172,4 +174,41 @@ describe('App title sync', () => {
'enabled',
);
});
test('原生壳直达二级页面时补齐 H5 返回锚点', async () => {
window.history.replaceState(
null,
'',
'/creation/puzzle?clientRuntime=native_app&hostShell=tauri_desktop&hostCapabilities=host.events,navigation.canGoBack',
);
window.__TAURI__ = {
core: {
invoke: vi.fn(async () => {
throw new Error('unsupported');
}),
},
};
renderApp();
expect(screen.getByTestId('selection-stage').textContent).toBe(
'puzzle-agent-workspace',
);
expect(window.location.pathname).toBe('/creation/puzzle');
expect(window.location.search).toBe(
'?clientRuntime=native_app&hostShell=tauri_desktop&hostCapabilities=host.events%2Cnavigation.canGoBack',
);
await act(async () => {
window.history.back();
});
await waitFor(() => {
expect(window.location.pathname).toBe('/');
});
expect(window.location.search).toBe(
'?clientRuntime=native_app&hostShell=tauri_desktop&hostCapabilities=host.events%2Cnavigation.canGoBack',
);
expect(screen.getByTestId('selection-stage').textContent).toBe('platform');
});
});
+36
View File
@@ -13,11 +13,14 @@ import type {
CustomWorldRuntimeLaunchOptions,
SelectionStage,
} from './components/platform-entry/platformEntryTypes';
import { useHostNavigationCanGoBack } from './hooks/useHostNavigationCanGoBack';
import type { HydratedSavedGameSnapshot } from './persistence/runtimeSnapshotTypes';
import {
APP_RUNTIME_ROUTES,
isAppHistoryState,
normalizeAppPath,
pushAppHistoryPath,
replaceAppHistoryPath,
readPublicWorkCodeFromLocationSearch,
resolvePathForSelectionStage,
resolveSelectionStageFromPath,
@@ -61,6 +64,10 @@ function isRpgRuntimeRoute(pathname: string) {
export default function App() {
const authUi = useAuthUi();
const runtimeIntentTokenRef = useRef(0);
const hasHostNavigationAnchorRef = useRef(
isAppHistoryState(window.history.state),
);
const hostNavigation = useHostNavigationCanGoBack();
const [runtimeIntent, setRuntimeIntent] =
useState<RpgRuntimeAppIntent | null>(null);
const [, setHostRuntimeRevision] = useState(0);
@@ -93,6 +100,10 @@ export default function App() {
useEffect(() => {
const syncStageFromHistory = () => {
hasHostNavigationAnchorRef.current = isAppHistoryState(
window.history.state,
);
if (isRpgRuntimeRoute(window.location.pathname)) {
setIsRuntimeActive(true);
return;
@@ -108,6 +119,31 @@ export default function App() {
return () => window.removeEventListener('popstate', syncStageFromHistory);
}, []);
useEffect(() => {
if (
!hostNavigation.isSupported ||
hostNavigation.canGoBack ||
isRuntimeActive ||
selectionStage === 'platform' ||
isAppHistoryState(window.history.state) ||
hasHostNavigationAnchorRef.current
) {
return;
}
const currentPath = normalizeAppPath(window.location.pathname);
const currentSearch = window.location.search;
hasHostNavigationAnchorRef.current = true;
replaceAppHistoryPath('/');
pushAppHistoryPath(`${currentPath}${currentSearch}`);
}, [
hostNavigation.canGoBack,
hostNavigation.isSupported,
isRuntimeActive,
selectionStage,
]);
const createRuntimeIntent = useCallback(
(intent: Omit<RpgRuntimeAppIntent, 'token'>) => {
runtimeIntentTokenRef.current += 1;
@@ -0,0 +1,121 @@
/* @vitest-environment jsdom */
import { act, renderHook } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import {
canUseNativeHostCapability,
subscribeHostNavigationCanGoBack,
subscribeHostRuntimeChange,
} from '../services/host-bridge/hostBridge';
import { useHostNavigationCanGoBack } from './useHostNavigationCanGoBack';
vi.mock('../services/host-bridge/hostBridge', () => ({
canUseNativeHostCapability: vi.fn(() => false),
subscribeHostNavigationCanGoBack: vi.fn(() => () => undefined),
subscribeHostRuntimeChange: vi.fn(() => () => undefined),
}));
const canUseNativeHostCapabilityMock = vi.mocked(canUseNativeHostCapability);
const subscribeHostNavigationCanGoBackMock = vi.mocked(
subscribeHostNavigationCanGoBack,
);
const subscribeHostRuntimeChangeMock = vi.mocked(subscribeHostRuntimeChange);
function setNavigationCapability(enabled: boolean) {
canUseNativeHostCapabilityMock.mockImplementation((capability) =>
enabled
? capability === 'host.events' || capability === 'navigation.canGoBack'
: false,
);
}
afterEach(() => {
vi.clearAllMocks();
});
test('host navigation can-go-back stays unsupported without both event capabilities', () => {
setNavigationCapability(false);
const { result } = renderHook(() => useHostNavigationCanGoBack());
expect(result.current).toEqual({
canGoBack: false,
isSupported: false,
});
expect(subscribeHostNavigationCanGoBackMock).not.toHaveBeenCalled();
});
test('host navigation can-go-back follows native app events', () => {
setNavigationCapability(true);
const unsubscribe = vi.fn();
let navigationListener:
| Parameters<typeof subscribeHostNavigationCanGoBack>[0]
| null = null;
subscribeHostNavigationCanGoBackMock.mockImplementation((listener) => {
navigationListener = listener;
return unsubscribe;
});
const { result, unmount } = renderHook(() =>
useHostNavigationCanGoBack(),
);
expect(result.current).toEqual({
canGoBack: false,
isSupported: true,
});
act(() => {
navigationListener?.({ canGoBack: true });
});
expect(result.current).toEqual({
canGoBack: true,
isSupported: true,
});
act(() => {
navigationListener?.({ canGoBack: false });
});
expect(result.current).toEqual({
canGoBack: false,
isSupported: true,
});
unmount();
expect(unsubscribe).toHaveBeenCalledTimes(1);
});
test('host navigation can-go-back subscribes after runtime refresh exposes capabilities', () => {
setNavigationCapability(false);
let runtimeListener: Parameters<typeof subscribeHostRuntimeChange>[0] | null =
null;
let navigationListener:
| Parameters<typeof subscribeHostNavigationCanGoBack>[0]
| null = null;
subscribeHostRuntimeChangeMock.mockImplementation((listener) => {
runtimeListener = listener;
return () => undefined;
});
subscribeHostNavigationCanGoBackMock.mockImplementation((listener) => {
navigationListener = listener;
return () => undefined;
});
const { result } = renderHook(() => useHostNavigationCanGoBack());
expect(result.current.isSupported).toBe(false);
setNavigationCapability(true);
act(() => {
runtimeListener?.();
});
expect(result.current.isSupported).toBe(true);
expect(subscribeHostNavigationCanGoBackMock).toHaveBeenCalledTimes(1);
act(() => {
navigationListener?.({ canGoBack: true });
});
expect(result.current.canGoBack).toBe(true);
});
+63
View File
@@ -0,0 +1,63 @@
import { useEffect, useState } from 'react';
import {
canUseNativeHostCapability,
subscribeHostNavigationCanGoBack,
subscribeHostRuntimeChange,
} from '../services/host-bridge/hostBridge';
function canUseHostNavigationCanGoBack() {
return (
canUseNativeHostCapability('host.events') &&
canUseNativeHostCapability('navigation.canGoBack')
);
}
export function useHostNavigationCanGoBack() {
const [isSupported, setIsSupported] = useState(() =>
canUseHostNavigationCanGoBack(),
);
const [canGoBack, setCanGoBack] = useState(false);
useEffect(() => {
let isDisposed = false;
let unsubscribeNavigation: () => void = () => {};
const syncSubscription = () => {
unsubscribeNavigation();
unsubscribeNavigation = () => {};
const nextIsSupported = canUseHostNavigationCanGoBack();
if (!isDisposed) {
setIsSupported(nextIsSupported);
}
if (!nextIsSupported) {
if (!isDisposed) {
setCanGoBack(false);
}
return;
}
unsubscribeNavigation = subscribeHostNavigationCanGoBack((payload) => {
if (!isDisposed) {
setCanGoBack(payload.canGoBack);
}
});
};
syncSubscription();
const unsubscribeRuntime = subscribeHostRuntimeChange(syncSubscription);
return () => {
isDisposed = true;
unsubscribeRuntime();
unsubscribeNavigation();
};
}, []);
return {
canGoBack,
isSupported,
};
}
+37
View File
@@ -3,7 +3,9 @@
import { describe, expect, it } from 'vitest';
import {
isAppHistoryState,
pushAppHistoryPath,
replaceAppHistoryPath,
resolvePathForSelectionStage,
resolveSelectionStageFromPath,
} from './appPageRoutes';
@@ -193,6 +195,41 @@ describe('appPageRoutes', () => {
);
});
it('keeps native app runtime context and marks app history entries', () => {
window.history.replaceState(
null,
'',
'/?clientRuntime=native_app&hostShell=tauri_desktop&hostPlatform=linux&hostVersion=0.1.0&bridgeVersion=1&hostCapabilities=host.getRuntime%2Chost.events%2Cnavigation.canGoBack',
);
pushAppHistoryPath('/creation/puzzle');
expect(window.location.pathname).toBe('/creation/puzzle');
expect(window.location.search).toBe(
'?clientRuntime=native_app&hostShell=tauri_desktop&hostPlatform=linux&hostVersion=0.1.0&bridgeVersion=1&hostCapabilities=host.getRuntime%2Chost.events%2Cnavigation.canGoBack',
);
expect(isAppHistoryState(window.history.state)).toBe(true);
});
it('replaces app history paths through the same runtime context rules', () => {
window.history.replaceState(
{ existing: true },
'',
'/creation/puzzle?clientRuntime=native_app&hostShell=expo_mobile&hostCapabilities=host.events%2Cnavigation.canGoBack',
);
replaceAppHistoryPath('/');
expect(window.location.pathname).toBe('/');
expect(window.location.search).toBe(
'?clientRuntime=native_app&hostShell=expo_mobile&hostCapabilities=host.events%2Cnavigation.canGoBack',
);
expect(window.history.state).toMatchObject({
existing: true,
});
expect(isAppHistoryState(window.history.state)).toBe(true);
});
it('clears creation restore query params when leaving the flow or switching flows', () => {
window.history.replaceState(
null,
+34 -1
View File
@@ -79,6 +79,7 @@ const ROUTE_STAGE_BY_PATH = new Map(
const APP_RUNTIME_CONTEXT_QUERY_KEYS =
HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS;
export const APP_HISTORY_STATE_KEY = '__genarrativeAppHistoryEntry';
export function normalizeAppPath(pathname: string) {
const trimmedPathname = pathname.trim().toLowerCase();
@@ -141,6 +142,14 @@ export function isKnownMainAppPagePath(pathname: string) {
}
export function pushAppHistoryPath(path: string) {
writeAppHistoryPath(path, 'push');
}
export function replaceAppHistoryPath(path: string) {
writeAppHistoryPath(path, 'replace');
}
function writeAppHistoryPath(path: string, mode: 'push' | 'replace') {
const nextUrl = new URL(path, window.location.origin);
const normalizedPath = normalizeAppPath(nextUrl.pathname);
const nextSearch = buildPreservedAppSearch(
@@ -156,7 +165,31 @@ export function pushAppHistoryPath(path: string) {
}
// 页面阶段变化是用户可感知导航,写入 history 以支持前进后退。
window.history.pushState(null, '', nextRelativeUrl);
if (mode === 'replace') {
window.history.replaceState(buildAppHistoryState(), '', nextRelativeUrl);
return;
}
window.history.pushState(buildAppHistoryState(), '', nextRelativeUrl);
}
export function isAppHistoryState(state: unknown) {
return Boolean(
state &&
typeof state === 'object' &&
(state as Record<string, unknown>)[APP_HISTORY_STATE_KEY] === true,
);
}
function buildAppHistoryState() {
const currentState =
window.history.state && typeof window.history.state === 'object'
? window.history.state
: {};
return {
...currentState,
[APP_HISTORY_STATE_KEY]: true,
};
}
function buildPreservedAppSearch(