接入原生返回栈状态消费
H5 新增 navigation.canGoBack hook 并在直达二级页时补齐返回锚点 路由导航保留完整原生宿主上下文并标记应用历史状态 补齐 HostBridge 返回栈消费测试、门禁和文档
This commit is contained in:
+40
-1
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user