From 33e99518aee9617e4ac550781ff92885f91d651a Mon Sep 17 00:00:00 2001 From: mackwang Date: Fri, 13 Nov 2026 17:44:11 +0800 Subject: [PATCH] fix(rn): preserve MpxNav first-page identity across navigation changes --- packages/core/__tests__/rn/createApp.spec.js | 5 + .../lib/runtime/components/react/mpx-nav.tsx | 9 +- .../components/react/types/global.d.ts | 2 + .../test/runtime/react-native/mpx-nav.spec.ts | 104 +++++++++++++++++- 4 files changed, 112 insertions(+), 8 deletions(-) diff --git a/packages/core/__tests__/rn/createApp.spec.js b/packages/core/__tests__/rn/createApp.spec.js index f0db9ca343..2d1d04d900 100644 --- a/packages/core/__tests__/rn/createApp.spec.js +++ b/packages/core/__tests__/rn/createApp.spec.js @@ -26,6 +26,11 @@ jest.mock('@mpxjs/utils', () => Object.assign({}, jest.requireActual('@mpxjs/uti jest.mock('@mpxjs/api-proxy', () => ({ createSelectorQuery: jest.fn(), createIntersectionObserver: jest.fn() })) +jest.mock('@react-navigation/native', () => ({ + useNavigationState: selector => selector({ routes: [{ key: 'home' }] }), + useRoute: () => ({ key: 'home' }) +}), { virtual: true }) + jest.mock('../../src/index', () => ({ config: { rnConfig: {}, ignoreProxyWhiteList: [] }, prototype: {} })) jest.mock('react-native', () => ({ diff --git a/packages/webpack-plugin/lib/runtime/components/react/mpx-nav.tsx b/packages/webpack-plugin/lib/runtime/components/react/mpx-nav.tsx index be14a48027..17d2dab696 100644 --- a/packages/webpack-plugin/lib/runtime/components/react/mpx-nav.tsx +++ b/packages/webpack-plugin/lib/runtime/components/react/mpx-nav.tsx @@ -1,5 +1,6 @@ /* eslint-disable space-before-function-paren */ import { useState, useMemo, memo } from 'react' +import { useNavigationState, useRoute } from '@react-navigation/native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { StatusBar, processColor, TouchableWithoutFeedback, Image, View, StyleSheet, Text } from 'react-native' @@ -89,6 +90,8 @@ const BACK_ICON = const MpxNav = memo(({ pageConfig, navigation }: MpxNavProps) => { const [innerPageConfig, setPageConfig] = useState(pageConfig || {}) const safeAreaTop = useSafeAreaInsets()?.top || 0 + const route = useRoute() + const isFirstPage = useNavigationState(state => state.routes[0].key === route.key) navigation.setPageConfig = (config: PageConfig) => { setPageConfig((previousConfig) => Object.assign({}, previousConfig, config)) @@ -104,18 +107,16 @@ const MpxNav = memo(({ pageConfig, navigation }: MpxNavProps) => { ) if (isCustom) return statusBarElement - // 假设是栈导航,获取栈的长度 - const stackLength = navigation.getState()?.routes?.length const onStackTopBack = mpxGlobal?.__mpx?.config?.rnConfig?.onStackTopBack const isHandleStackTopBack = typeof onStackTopBack === 'function' // 回退按钮与图标 // prettier-ignore - const backElement = stackLength > 1 || isHandleStackTopBack + const backElement = !isFirstPage || isHandleStackTopBack ? ( { - if (stackLength <= 1 && isHandleStackTopBack) { + if (isFirstPage && isHandleStackTopBack) { onStackTopBack() return } diff --git a/packages/webpack-plugin/lib/runtime/components/react/types/global.d.ts b/packages/webpack-plugin/lib/runtime/components/react/types/global.d.ts index f365984d19..1a96ad39c6 100644 --- a/packages/webpack-plugin/lib/runtime/components/react/types/global.d.ts +++ b/packages/webpack-plugin/lib/runtime/components/react/types/global.d.ts @@ -49,6 +49,8 @@ declare let global: { declare let mpxGlobal: Record declare module '@react-navigation/native' { + export function useNavigationState(selector: (state: { routes: { key: string }[] }) => T): T + export function useRoute (): { key: string } export function useNavigation (): Record export function usePreventRemove( enabled: boolean, diff --git a/packages/webpack-plugin/test/runtime/react-native/mpx-nav.spec.ts b/packages/webpack-plugin/test/runtime/react-native/mpx-nav.spec.ts index a949ecd1bc..4babd154dc 100644 --- a/packages/webpack-plugin/test/runtime/react-native/mpx-nav.spec.ts +++ b/packages/webpack-plugin/test/runtime/react-native/mpx-nav.spec.ts @@ -16,14 +16,57 @@ jest.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({ top: 20 }) })) +jest.mock('@react-navigation/native', () => { + const React = jest.requireActual('react') + const NavigationContext = React.createContext(null) + return { + NavigationContext, + useRoute: () => React.useContext(NavigationContext).route, + useNavigationState: (selector: (state: any) => boolean) => { + const { navigation } = React.useContext(NavigationContext) + return React.useSyncExternalStore( + React.useCallback((callback: () => void) => navigation.addListener('state', callback), [navigation]), + () => selector(navigation.getState()) + ) + } + } +}, { virtual: true }) + +const { NavigationContext } = jest.requireMock('@react-navigation/native') +const renderNav = (navigation: any, pageConfig = {}, routeKey = 'home') => React.createElement( + NavigationContext.Provider, { value: { navigation, route: { key: routeKey } } }, + React.createElement(MpxNav, { pageConfig, navigation }) +) + describe('RN built-in navigation bar', () => { + const createNavigation = (routeKeys = ['home']) => { + let state = { routes: routeKeys.map(key => ({ key })) } + const listeners = new Set<() => void>() + return { + getState: () => state, + goBack: jest.fn(), + addListener: jest.fn((event: string, callback: () => void) => { + expect(event).toBe('state') + listeners.add(callback) + return () => listeners.delete(callback) + }), + setRoutes: (keys: string[], notify = true) => { + state = { routes: keys.map(key => ({ key })) } + if (notify) listeners.forEach(callback => callback()) + }, + listeners + } + } + + afterEach(() => { + delete (global as any).__mpx.config.rnConfig + }) + test('merges consecutive title and color updates in the same batch', () => { - const navigation: any = { getState: () => ({ routes: [{}] }) } + const navigation: any = createNavigation() let renderer: any act(() => { - renderer = create(React.createElement(MpxNav, { - pageConfig: { navigationBarTitleText: 'Initial' }, navigation - })) + renderer = create(renderNav(navigation, { navigationBarTitleText: 'Initial' })) }) act(() => { navigation.setPageConfig({ navigationBarTitleText: 'Updated' }) @@ -36,4 +79,57 @@ describe('RN built-in navigation bar', () => { expect(renderer.root.findByType('StatusBar').props.barStyle).toBe('dark-content') act(() => renderer.unmount()) }) + + test('keeps the home page identity while a detail page is open and after returning', () => { + const onStackTopBack = jest.fn() + ;(global as any).__mpx.config.rnConfig = { onStackTopBack } + const navigation = createNavigation(['home', 'detail']) + let renderer: any + act(() => { + renderer = create(renderNav(navigation)) + }) + renderer.root.findByType('TouchableWithoutFeedback').props.onPress() + expect(onStackTopBack).toHaveBeenCalledTimes(1) + expect(navigation.goBack).not.toHaveBeenCalled() + + act(() => renderer.update(renderNav(navigation, {}, 'detail'))) + renderer.root.findByType('TouchableWithoutFeedback').props.onPress() + expect(navigation.goBack).toHaveBeenCalledTimes(1) + + // 模拟后台首页重渲染后,栈已回退但导航栏尚未重渲染。 + act(() => renderer.update(renderNav(navigation))) + const onPress = renderer.root.findByType('TouchableWithoutFeedback').props.onPress + navigation.setRoutes(['home'], false) + onPress() + expect(onStackTopBack).toHaveBeenCalledTimes(2) + expect(navigation.goBack).toHaveBeenCalledTimes(1) + act(() => renderer.unmount()) + }) + + test('updates back button visibility on state changes and cleans up subscriptions', () => { + const navigation = createNavigation() + let renderer: any + act(() => { + renderer = create(renderNav(navigation)) + }) + expect(renderer.root.findAllByType('TouchableWithoutFeedback')).toHaveLength(0) + act(() => navigation.setRoutes(['home', 'detail'])) + expect(renderer.root.findAllByType('TouchableWithoutFeedback')).toHaveLength(0) + act(() => navigation.setRoutes(['detail', 'home'])) + expect(renderer.root.findAllByType('TouchableWithoutFeedback')).toHaveLength(1) + act(() => navigation.setRoutes(['home'])) + expect(renderer.root.findAllByType('TouchableWithoutFeedback')).toHaveLength(0) + + const nextNavigation = createNavigation(['detail', 'home']) + act(() => { + renderer.update(renderNav(nextNavigation)) + }) + expect(navigation.listeners.size).toBe(0) + expect(nextNavigation.listeners.size).toBe(1) + act(() => renderer.root.findByType('TouchableWithoutFeedback').props.onPress()) + expect(nextNavigation.goBack).toHaveBeenCalledTimes(1) + expect(navigation.goBack).not.toHaveBeenCalled() + act(() => renderer.unmount()) + expect(nextNavigation.listeners.size).toBe(0) + }) })