diff --git a/.agents/skills/mpx2rn/references/rn-script-reference.md b/.agents/skills/mpx2rn/references/rn-script-reference.md index 7c301c9379..3d869c6b46 100644 --- a/.agents/skills/mpx2rn/references/rn-script-reference.md +++ b/.agents/skills/mpx2rn/references/rn-script-reference.md @@ -149,7 +149,7 @@ | `pageLifetimes.show` | 组件 | 所在页面展示或重新获得焦点时触发,与页面 `onShow` 时机对齐。 | | `pageLifetimes.hide` | 组件 | 所在页面隐藏或失焦时触发,与页面 `onHide` 时机对齐。 | | `pageLifetimes.resize` | 组件 | 所在页面可视区域尺寸变化时触发,与页面 `onResize` 时机对齐。 | -| `onLoad` | 页面 | 页面创建后调用,**两个参数** `(rawQuery, decodedQuery)`。 | +| `onLoad` | 页面 | 页面创建后调用,**两个参数** `(rawQuery, decodedQuery)`;RN 根组件初始化时,首个页面实例会收到 `parseAppProps` 返回的 `initialParams`,后续创建的同路径页面实例不会自动继承。 | | `created` | 组件 | 组件实例刚创建,RN 由 `MpxProxy` 在实例建立阶段调度,此时不宜依赖完整视图。 | | `attached` | 组件 | 组件进入节点树,RN 对齐为挂载流程中的对应阶段,详见 `docs-vitepress/guide/basic/lifecycle.md` 映射表。 | | `ready` | 组件 | 组件布局完成、可与视图交互,RN 对应 React 挂载后的就绪时机,与页面 `onReady` 同属一套内置映射。 | @@ -717,7 +717,7 @@ Mpx.config.rnConfig = { | 配置项 | 说明 | | --- | --- | | `projectName` | 由构建注入到 RN 入口,与 `AppRegistry.registerComponent` 相关(偏构建侧)。 | -| `parseAppProps` | `(props) => { initialRouteName?, initialParams? }`,解析外层传入 App 根组件的初始路由。 | +| `parseAppProps` | `(props) => { initialRouteName?, initialParams? }`,解析外层传入 App 根组件的初始路由;`initialParams` 作为 RN 根组件初始化时首个路由实例的参数,会传入该页面的 `onLoad`,并作为初始化阶段应用 `onLaunch` / `onShow` 的 `query`。后续创建的同路径页面实例不会自动继承;应用再次展示时,`onShow` 参数中的 `query` 以当前路由实例的参数为准。`initialRouteName` 未注册时,Mpx 会通过统一错误处理上报(配置了 `Mpx.config.errorHandler` 时会触发该回调),回退至应用首页,并丢弃该错误路由的 `initialParams`。 | | `onStateChange` | 导航 state 变化时回调。 | | `disablePageTransition` | 为 `true` 时禁用 RN 页面转场动画,框架内部映射为 `animation: "none"`。 | | `disableAppStateListener` | 为 `true` 时不注册 `AppState` 监听(避免与宿主 App 重复)。 | diff --git a/docs-vitepress/guide/rn/application-api.md b/docs-vitepress/guide/rn/application-api.md index c16e0e29ca..dde6607ef0 100644 --- a/docs-vitepress/guide/rn/application-api.md +++ b/docs-vitepress/guide/rn/application-api.md @@ -416,8 +416,8 @@ createComponent({ 用于获取初始路由配置的函数,参数为 RN 根组件接收到的参数 -- initialRouteName: 首页路径,例如 pages/index -- initialParams: 将作为 首页onLoad 与 应用onLaunch 的参数,例如 \{ a: 1 \} +- initialRouteName: 首页路径,例如 pages/index。若该路径未注册,Mpx 会通过统一错误处理上报(配置了 `Mpx.config.errorHandler` 时会触发该回调),回退至应用首页,并丢弃该错误路由的 `initialParams` +- initialParams: 作为 RN 根组件初始化时首个路由实例的参数,会传入该页面的 `onLoad`,并作为初始化阶段应用 `onLaunch` / `onShow` 的 `query`,例如 \{ a: 1 \}。后续通过路由 API 再次打开同路径页面时不会自动继承;应用再次展示时,`onShow` 参数中的 `query` 以当前路由实例的参数为准 在需要将 RN 应用嵌入到现有的 NA 应用中时,NA 可能会向 RN 的根组件传递 props,此时可在 parseAppProps 中接受 props 并进行处理和透传到页面 diff --git a/packages/core/__tests__/common/createApp.ios.spec.js b/packages/core/__tests__/common/createApp.ios.spec.js new file mode 100644 index 0000000000..d8d863e89c --- /dev/null +++ b/packages/core/__tests__/common/createApp.ios.spec.js @@ -0,0 +1,122 @@ +global.__mpx_mode__ = 'ios' + +const createApp = require('../../src/platform/createApp.ios').default +const transferOptions = require('../../src/core/transferOptions') +const Mpx = require('../../src/index') +const { error } = require('@mpxjs/utils') + +jest.mock('../../src/core/transferOptions', () => jest.fn()) + +jest.mock('@mpxjs/utils', () => Object.assign({}, jest.requireActual('@mpxjs/utils'), { + error: jest.fn() +})) + +jest.mock('../../src/index', () => ({ config: { rnConfig: {} }, prototype: {} })) + +jest.mock('../../src/observer/reactive', () => ({ + reactive: (value) => value +})) + +jest.mock('../../src/observer/watch', () => ({ + watch: jest.fn() +})) + +jest.mock('react', () => ({ + createElement: (type, props, ...children) => ({ + type, + props: Object.assign({}, props, { children }) + }), + memo: (component) => component, + useRef: (value) => ({ current: value }), + useEffect: jest.fn() +}), { virtual: true }) + +jest.mock('react-native', () => ({}), { virtual: true }) + +jest.mock('../../src/platform/export/inject', () => ({ + initAppProvides: jest.fn() +})) + +jest.mock('../../src/platform/env/navigationHelper', () => ({ + NavigationContainer: 'NavigationContainer', + createNativeStackNavigator: () => ({ + Navigator: 'StackNavigator', + Screen: 'StackScreen' + }), + SafeAreaProvider: 'SafeAreaProvider', + GestureHandlerRootView: 'GestureHandlerRootView' +}), { virtual: true }) + +jest.mock('@mpxjs/webpack-plugin/lib/runtime/components/react/dist/mpx-nav', () => 'MpxNav', { virtual: true }) + +describe('RN createApp initial params', () => { + const onLaunch = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + global.__mpxOptionsMap = {} + global.__mpxPageConfig = {} + global.__mpxPageConfigsMap = {} + Mpx.config.rnConfig = {} + transferOptions.mockReturnValue({ + rawOptions: { onLaunch }, + currentInject: { + moduleId: 'app', + firstPage: 'pages/home', + pagesMap: { + 'pages/home': () => null, + 'pages/index': () => null + } + } + }) + }) + + function renderApp (initialRouteName, initialParams) { + Mpx.config.rnConfig.parseAppProps = () => ({ initialRouteName, initialParams }) + createApp({}) + return global.__mpxOptionsMap.app({}).props.children[0] + } + + it.each([ + ['pages/index', 'pages/index'], + [undefined, 'pages/home'] + ])('uses initialState when initialRouteName is %s', (initialRouteName, expectedRouteName) => { + const initialParams = { a: 1 } + const navigationContainer = renderApp(initialRouteName, initialParams) + const stackNavigator = navigationContainer.props.children[0] + + expect(error).not.toHaveBeenCalled() + expect(navigationContainer.props.initialState).toEqual({ + routes: [{ + name: expectedRouteName, + params: initialParams + }] + }) + expect(stackNavigator.props).not.toHaveProperty('initialRouteName') + stackNavigator.props.children.forEach(screen => { + expect(screen.props).not.toHaveProperty('initialParams') + }) + + global.__mpxAppOnLaunch({ + getState: () => Object.assign({ index: 0 }, navigationContainer.props.initialState) + }) + expect(onLaunch).toHaveBeenCalledWith(expect.objectContaining({ + path: expectedRouteName, + query: initialParams, + isLaunch: true + })) + }) + + it('reports and ignores an unregistered initial route and its params', () => { + const navigationContainer = renderApp('pages/missing', { fromMissing: true }) + + expect(error).toHaveBeenCalledTimes(1) + expect(error).toHaveBeenCalledWith('The initial page [pages/missing] is not registered in the application. Mpx will ignore this initial route configuration.') + expect(navigationContainer.props.initialState).toEqual({ + routes: [{ + name: 'pages/home', + params: {} + }] + }) + }) +}) diff --git a/packages/core/jest.config.json b/packages/core/jest.config.json index 04f974ba52..d27215cd75 100644 --- a/packages/core/jest.config.json +++ b/packages/core/jest.config.json @@ -2,5 +2,9 @@ "transform": { "^.+\\.(js|jsx)?$": ["babel-jest", { "rootMode": "upward" }] }, - "testEnvironment": "jsdom" + "testEnvironment": "jsdom", + "globals": { + "__mpx_perf__": false, + "__mpx_perf_framework__": false + } } diff --git a/packages/core/src/platform/createApp.ios.js b/packages/core/src/platform/createApp.ios.js index c6aeaf67f2..32f959c26f 100644 --- a/packages/core/src/platform/createApp.ios.js +++ b/packages/core/src/platform/createApp.ios.js @@ -1,6 +1,6 @@ import transferOptions from '../core/transferOptions' import builtInKeysMap from './patch/builtInKeysMap' -import { makeMap, spreadProp, getFocusedNavigation, hasOwn, callWithErrorHandling } from '@mpxjs/utils' +import { makeMap, spreadProp, getFocusedNavigation, hasOwn, callWithErrorHandling, error } from '@mpxjs/utils' import { mergeLifecycle } from '../convertor/mergeLifecycle' import { LIFECYCLE } from '../platform/patch/lifecycle/index' import Mpx from '../index' @@ -54,7 +54,7 @@ export default function createApp (options) { const pagesMap = currentInject.pagesMap || {} const firstPage = currentInject.firstPage const Stack = createNativeStackNavigator() - const getPageScreens = (initialRouteName, initialParams) => { + const getPageScreens = () => { return Object.entries(pagesMap).map(([key, item]) => { const pageConfig = Object.assign({}, global.__mpxPageConfig, global.__mpxPageConfigsMap[key]) const headerLayout = ({ navigation, children }) => { @@ -74,14 +74,6 @@ export default function createApp (options) { const getComponent = () => { return item.displayName ? item : callWithErrorHandling(item, null, 'require page script') } - if (key === initialRouteName) { - return createElement(Stack.Screen, { - name: key, - getComponent, - initialParams, - layout: headerLayout - }) - } return createElement(Stack.Screen, { name: key, getComponent, @@ -173,8 +165,16 @@ export default function createApp (options) { } if (!global.__mpxAppHotLaunched) { const { initialRouteName, initialParams } = Mpx.config.rnConfig.parseAppProps?.(props) || {} - initialRouteRef.current.initialRouteName = initialRouteName || initialRouteRef.current.initialRouteName - initialRouteRef.current.initialParams = initialParams || initialRouteRef.current.initialParams + if (initialRouteName && !hasOwn(pagesMap, initialRouteName)) { + error(`The initial page [${initialRouteName}] is not registered in the application. Mpx will ignore this initial route configuration.`) + } else { + if (initialRouteName) { + initialRouteRef.current.initialRouteName = initialRouteName + } + if (initialParams) { + initialRouteRef.current.initialParams = initialParams + } + } global.__mpxAppOnLaunch = (navigation) => { const state = navigation.getState() @@ -213,6 +213,12 @@ export default function createApp (options) { }, []) const { initialRouteName, initialParams } = initialRouteRef.current + const initialState = { + routes: [{ + name: initialRouteName, + params: initialParams + }] + } const navScreenOpts = { headerShown: false } @@ -224,15 +230,15 @@ export default function createApp (options) { null, createElement(NavigationContainer, { + initialState, onStateChange, onUnhandledAction }, createElement(Stack.Navigator, { - initialRouteName, screenOptions: navScreenOpts }, - ...getPageScreens(initialRouteName, initialParams) + ...getPageScreens() ) ) )