Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .agents/skills/mpx/references/rn/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
| --- | --- |
| 基础组件、属性、事件、无障碍、i18n | [RN 模板与组件支持表](./rn-template-reference.md) |
| 构造选项、生命周期、selector、实例方法、宿主钩子 | [RN 脚本差异](./rn-script-reference.md) |
| 页面预加载、`preload`、`isPreload`、EventChannel 获取时机 | [preload API](./rn-api-reference.md#preload) 与 [预加载生命周期及事件通道](./rn-script-reference.md#预加载页面的生命周期与事件通道) |
| 样式改造/开发 | 优先 [样式实践](./rn-style-practice.md) 命中的场景;未命中再查能力表。 |
| 明确查询某个选择器/单位/样式属性是否支持 | 直接查 [样式能力](./rn-style-reference.md)。 |
| UnoCSS / utility class | [原子 CSS 基准](../base/atomic-css.md)与 [RN 差异](./rn-atomic-css.md),仅在接入或使用时加载。 |
Expand Down
23 changes: 22 additions & 1 deletion .agents/skills/mpx/references/rn/rn-api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
- [onLazyLoadError](../base/api-reference.md#onlazyloaderror)(基准)
- [offLazyLoadError](../base/api-reference.md#offlazyloaderror)(基准)
- [路由](#路由)
- [preload](#preload)(RN 扩展)
- [navigateTo](../base/api-reference.md#navigateto)(基准)
- [redirectTo](../base/api-reference.md#redirectto)(基准)
- [reLaunch](../base/api-reference.md#relaunch)(基准)
Expand Down Expand Up @@ -179,7 +180,27 @@

## 路由

用法见索引中的基准链接。
除下述 RN 扩展外,用法见索引中的基准链接。

### preload

`mpx.preload({ url }): void` 是 RN 扩展,用于在不跳转的情况下提前创建、渲染目标页面。支持 React Navigation 7.x Native Stack;自定义导航器需另行确认预加载支持情况。

| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `url` | `string` | 是 | 非空页面路径,支持相对路径和 query 参数。 |

无返回值,不提供 `success` / `fail` / `complete` 回调或就绪 Promise;默认不参与 API Promise 化,不应通过 `whiteList` 强制 Promise 化。调用结束不代表目标页或数据已加载完成。

```js
mpx.preload({ url: '/pages/detail?id=1' })
// 需要展示时再跳转
mpx.navigateTo({ url: '/pages/detail?id=1' })
```

预加载与跳转参数不同时,可能仍复用同一实例;建议两次调用使用相同参数。`onLoad` 第三个参数 `isPreload`、正式进入时的生命周期和 EventChannel 获取时机见 [预加载页面的生命周期与事件通道](./rn-script-reference.md#预加载页面的生命周期与事件通道)。

`redirectTo` 也可复用同路径的预加载路由来替换当前页,页面参数以本次跳转的 query 为准,不合并预加载参数;其他页面及预加载记录保留。未命中或导航器不支持预加载时,保持普通替换行为。

## 界面

Expand Down
11 changes: 11 additions & 0 deletions .agents/skills/mpx/references/rn/rn-script-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
- [App 构造选项](#app-构造选项)
- [页面与组件构造选项](#页面与组件构造选项)
- [统一生命周期](#统一生命周期)
- [预加载页面的生命周期与事件通道](#预加载页面的生命周期与事件通道)
- [关系能力](#关系能力)
- [通过 implement 适配构造选项](#通过-implement-适配构造选项)
- [实例属性与方法](#实例属性与方法)
Expand Down Expand Up @@ -93,10 +94,19 @@ methods: {

| 生命周期 / 钩子 | RN 差异 |
| --- | --- |
| 页面 `onLoad` / `onLoad(callback)` | 回调签名为 `(rawQuery, decodedQuery, isPreload)`,前两个参数沿用基准;第三个参数为 `boolean`,表示本次初始化是否发生在预加载阶段,普通打开或入栈后才初始化时为 `false`。见 [预加载生命周期](#预加载页面的生命周期与事件通道)。 |
| `moved` / `error` | 不支持。 |
| `onReactHooksExec` / `REACTHOOKSEXEC` | RN 混编专属,用于执行 React Hooks,见 [混合开发](./rn-hybrid-dev.md)。 |
| `onServerPrefetch` / `SERVERPREFETCH` | RN 不支持 SSR,不会执行。 |

### 预加载页面的生命周期与事件通道

通过 [preload](./rn-api-reference.md#preload) 创建页面时,提前执行 `onLoad(rawQuery, decodedQuery, true)`,不触发 `onShow/onHide`。正式进入并复用该实例时触发 `onShow`,不重复执行 `onLoad`;跳转参数变化也不意味着 `onLoad` 会重新执行。

预加载阶段 `getOpenerEventChannel()` 返回 `undefined`。在正式进入的 `onShow` 中获取本次 `navigateTo` 创建的 EventChannel;页面的 setup context 提供的同名方法也应在这个时机调用,不要在 setup 初始化或预加载的 onLoad 中提前缓存通道。

后续再次显示同一页面时保持已有通道,应避免在每次 `onShow` 重复注册监听。发送方需确认接收方已就绪后再发送消息。通道的 `emit/on/once/off` 用法沿用 [页面事件通道基准](../base/script-reference.md#页面与组件实例属性与方法)。

### 关系能力

- 仅支持按组件路径关联,不支持通过 `target` 按 behavior 匹配。关系键须与目标组件的构建路径一致,可用 `?resolve` 导入路径;双方仍须声明配对关系。
Expand All @@ -111,6 +121,7 @@ methods: {

| 名称 | 类型 | 适用 | RN 差异 |
| --- | --- | --- | --- |
| `getOpenerEventChannel()` | 方法 | 页面 | 预加载阶段返回 `undefined`;正式进入后获取通道的时机见 [预加载生命周期与事件通道](#预加载页面的生命周期与事件通道)。 |
| `triggerEvent(name, detail, options)` | 方法 | 组件 | 仅调用当前组件节点上匹配的事件处理器,不处理第三个参数中的 `bubbles`、`composed`、`capturePhase`,不实现自定义事件沿祖先节点冒泡或捕获。 |
| `getTabBar()` | 方法 | 通用 | RN 不提供该方法,也不支持 JSON `tabBar` 配置。 |
| `selectComponent(selector)` / `selectAllComponents(selector)` | 方法 | 通用 | 目标自定义组件须声明 `wx:ref`,空值或具名均可;不返回基础组件节点。无匹配时分别返回 `undefined` / `[]`。selector 范围见 [selector 查询要求](#selector-查询要求)。 |
Expand Down
1 change: 1 addition & 0 deletions docs-vitepress/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,7 @@ const sidebar: ExtendedSidebar = {
text: '路由',
collapsed: true,
items: [
{ text: 'preload(RN)', link: '/api-proxy/route/preload' },
{ text: 'navigateTo', link: '/api-proxy/route/navigateTo' },
{ text: 'redirectTo', link: '/api-proxy/route/redirectTo' },
{ text: 'switchTab', link: '/api-proxy/route/switchTab' },
Expand Down
2 changes: 2 additions & 0 deletions docs-vitepress/api-proxy/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ mpx.use(apiProxy, options)

应用事件可通过监听 API 注册。例如,[onPageNotFound](./base/app/app-event/onPageNotFound.md) 监听启动入口不存在事件,[offPageNotFound](./base/app/app-event/offPageNotFound.md) 取消监听。

RN 页面可通过 [preload](./route/preload.md) 提前创建目标页面,再通过 `navigateTo` 正式进入。支持范围、生命周期与页面通信时机见该 API 说明。

## 配置项 (Options) {#options}

所有配置均为可选,可根据实际需求组合使用
Expand Down
4 changes: 3 additions & 1 deletion docs-vitepress/api-proxy/route/navigateTo.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

支持情况: 微信、支付宝、RN、web

> RN 中通过 [preload](./preload.md) 预加载的页面,应在正式进入的 `onShow` 中获取 EventChannel,并在接收方就绪后发送消息,详见 [预加载注意事项](./preload.md#notes)。

[参考文档](https://developers.weixin.qq.com/miniprogram/dev/api/route/wx.navigateTo.html)

### 参数 {#parameters}
Expand Down Expand Up @@ -53,4 +55,4 @@ mpx.navigateTo({
}
})

```
```
25 changes: 25 additions & 0 deletions docs-vitepress/api-proxy/route/preload.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# mpx.preload({ url })

预加载目标页面,提前完成页面创建和渲染,不发生页面跳转。仅支持 React Native 端,使用 React Navigation 7.x Native Stack。

## 用法 {#usage}

```js
// 预加载详情页
mpx.preload({ url: '/pages/detail?id=1' })

// 需要展示时再跳转
mpx.navigateTo({ url: '/pages/detail?id=1' })
```

`url`:必填,类型为 `string`,表示目标页面路径,支持相对路径和 query 参数。

无返回值,不提供完成回调或 Promise。调用结束不代表页面或数据已加载完成。

## 注意事项 {#notes}

- 预加载会提前执行 `onLoad`,但不触发 `onShow/onHide`。正式进入并复用该页面时触发 `onShow`,不重复执行 `onLoad`。
- RN 的 `onLoad(query, decodedQuery, isPreload)` 第三个参数表示本次初始化是否发生在预加载阶段;普通打开或入栈后才初始化时为 `false`。前两个参数保持原有含义,分别为原始 query 和解码后的 query。

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] 补齐文档入口及 Skill 同步

新增 preload.md 后,还需要在 docs-vitepress/.vitepress/config.ts 的路由 API 侧边栏添加入口,并同步 .agents/skills/mpx/references/rn/rn-api-reference.md、rn-script-reference.md 及相关索引,说明 preload、onLoad 第三个参数 isPreload,以及 EventChannel 的获取时机。当前 PR 未包含这些更新,文档缺少侧边栏入口,Skill 也无法反映新增能力;这也是仓库 AGENTS.md 要求在同一次改动中同步的内容。

- 预加载与跳转参数不同时,可能仍复用同一实例,业务需自行处理参数变化。建议两次调用使用相同参数。
- 也可通过 [redirectTo](./redirectTo.md) 进入同路径的预加载页面并替换当前页;页面参数以本次跳转的 query 为准,不合并预加载参数。
- 预加载阶段 `getOpenerEventChannel()` 返回 `undefined`。业务应在正式进入的 `onShow` 中获取本次 `navigateTo` 创建的通道;后续再次显示时保持该通道。重复显示时请避免重复注册;发送消息前需确认接收方已就绪。
2 changes: 2 additions & 0 deletions docs-vitepress/api-proxy/route/redirectTo.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

支持情况: 微信、支付宝、RN、web

RN 端命中 [preload](./preload.md) 预加载的同路径页面时,复用该路由替换当前页,页面参数以本次 `redirectTo` 的 query 为准,不合并预加载参数;其他页面及预加载记录保留。未命中或当前导航器不支持预加载时,保持普通替换行为。

[参考文档](https://developers.weixin.qq.com/miniprogram/dev/api/route/wx.redirectTo.html)

### 参数 {#parameters}
Expand Down
7 changes: 7 additions & 0 deletions packages/api-proxy/@types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,15 @@ interface RequestPaymentInterface extends WechatMiniprogram.RequestPaymentOption
tradeNO?: string
}

/** RN Native Stack only. Returns immediately; no callbacks or readiness Promise. */
export interface PreloadOption {
url: string
}
export const preload: (options: PreloadOption) => void

declare module '@mpxjs/core' {
interface Mpx extends AddPromise<WechatMiniprogram.Wx> {
preload: typeof preload
requestPayment: AddParam<RequestPaymentInterface, PickApiValue<'requestPayment'>>
}
}
Expand Down
107 changes: 107 additions & 0 deletions packages/api-proxy/__tests__/rn/route.spec.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { setFocusedNavigation } from '@mpxjs/utils'
import { navigateTo, redirectTo, navigateBack, reLaunch, switchTab } from '../../src/platform/api/route/index.ios'
import promisify from '../../src/common/js/promisify'

describe('RN route APIs', () => {
beforeEach(() => {
Expand Down Expand Up @@ -70,6 +71,112 @@ describe('RN route APIs', () => {
expect(global.__mpxPagesMap.main[1].pop).toHaveBeenCalledWith(1)
})

describe('redirectTo with preloaded routes', () => {
let navigation
let state

beforeEach(() => {
state = {
stale: false,
type: 'stack',
key: 'stack-key',
index: 1,
routeNames: ['pages/home', 'pages/current', 'pages/other', 'pages/target'],
routes: [
{ key: 'home-key', name: 'pages/home' },
{ key: 'current-key', name: 'pages/current' },
{ key: 'other-key', name: 'pages/other', params: { keep: 'stack' } }
],
preloadedRoutes: [
{ key: 'target-key', name: 'pages/target', params: { id: 'old', preloadOnly: 'yes' } },
{ key: 'other-preload-key', name: 'pages/other', params: { keep: 'preload' } }
]
}
navigation = global.__mpxPagesMap.main[1]
navigation.getState = () => state
navigation.preload = jest.fn()
})

test.each([
['./target?id=new&text=a%20b', { id: 'new', text: 'a%20b' }],
['/pages/target', {}]
])('reuses the preload key and replaces params for %s without changing other routes', (url, params) => {
const originalState = JSON.parse(JSON.stringify(state))

redirectTo({ url })

expect(navigation.replace).not.toHaveBeenCalled()
expect(navigation.reset).toHaveBeenCalledTimes(1)
const nextState = navigation.reset.mock.calls[0][0]
expect(nextState).toEqual(Object.assign({}, state, {
routes: [state.routes[0], { key: 'target-key', name: 'pages/target', params }, state.routes[2]],
preloadedRoutes: [state.preloadedRoutes[1]]
}))
expect(nextState.routes[0]).toBe(state.routes[0])
expect(nextState.routes[2]).toBe(state.routes[2])
expect(nextState.preloadedRoutes[0]).toBe(state.preloadedRoutes[1])
expect(nextState).not.toBe(state)
expect(state).toEqual(originalState)
})

test.each(['miss', 'unsupported'])('keeps replace for a preload %s', (scenario) => {
if (scenario === 'miss') state.preloadedRoutes = [state.preloadedRoutes[1]]
else delete navigation.preload

redirectTo({ url: './target?id=new' })

expect(navigation.replace).toHaveBeenCalledWith('pages/target', { id: 'new' })
expect(navigation.reset).not.toHaveBeenCalled()
})

test('waits for navigation success, blocks concurrent redirects and resolves the Promise', async () => {
const complete = jest.fn()
const pending = promisify({ redirectTo }).redirectTo({ url: './target', complete })
const fail = jest.fn()
const blockedComplete = jest.fn()

redirectTo({ url: './other', fail, complete: blockedComplete })

expect(fail).toHaveBeenCalledWith({
errMsg: "redirectTo:fail the previous routing event didn't complete",
path: './other'
})
expect(blockedComplete).toHaveBeenCalledWith(fail.mock.calls[0][0])
expect(navigation.reset).toHaveBeenCalledTimes(1)
expect(navigation.replace).not.toHaveBeenCalled()
expect(complete).not.toHaveBeenCalled()

// Simulate createApp's onStateChange settling the pending navigation.
const callback = global.__navigationHelper.lastSuccessCallback
global.__navigationHelper.lastSuccessCallback = null
global.__navigationHelper.lastFailCallback = null
callback()

await expect(pending).resolves.toEqual({ errMsg: 'redirectTo:ok' })
jest.advanceTimersByTime(1000)
expect(complete).toHaveBeenCalledTimes(1)
expect(complete).toHaveBeenCalledWith({ errMsg: 'redirectTo:ok' })
redirectTo({ url: './other' })
expect(navigation.reset).toHaveBeenCalledTimes(2)
})

test('rejects on timeout, completes once and releases the navigation lock', async () => {
const complete = jest.fn()
const pending = promisify({ redirectTo }).redirectTo({ url: './target', complete })
const rejection = expect(pending).rejects.toEqual({ errMsg: 'redirectTo:fail timeout ./target' })

jest.advanceTimersByTime(2000)
await rejection

expect(complete).toHaveBeenCalledTimes(1)
expect(complete).toHaveBeenCalledWith({ errMsg: 'redirectTo:fail timeout ./target' })
expect(global.__navigationHelper.lastSuccessCallback).toBeNull()
expect(global.__navigationHelper.lastFailCallback).toBeNull()
redirectTo({ url: './other' })
expect(navigation.reset).toHaveBeenCalledTimes(2)
})
})

test('switchTab should report an unsupported environment error', () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation()

Expand Down
1 change: 1 addition & 0 deletions packages/api-proxy/src/common/js/promisify.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const blackList = [
'reportPerformance',
'router',
'nextTick',
'preload',
'checkIsPictureInPictureActive',
'worklet',
'revokeBufferURL',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export const SUPPORTED_APIS = [
'request',

// route
'preload',
'redirectTo',
'navigateTo',
'navigateBack',
Expand Down
43 changes: 41 additions & 2 deletions packages/api-proxy/src/platform/api/route/index.ios.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { envError, successHandle, failHandle, resolvePath, getFocusedNavigation } from '../../../common/js'
import { parseUrlQuery as parseUrl } from '@mpxjs/utils'
import { envError, error, successHandle, failHandle, resolvePath, getFocusedNavigation } from '../../../common/js'
import { parseUrlQuery as parseUrl, isPreloadSupported } from '@mpxjs/utils'
import { nextTick } from '../next-tick'
import { EventChannel } from '../event-channel'

Expand Down Expand Up @@ -38,6 +38,33 @@ function canNavigate (navigation, navigationHelper, type, options) {
return true
}

function preload (options = {}) {
const navigation = getFocusedNavigation()
if (!isPreloadSupported(navigation)) {
error('preload: current navigator does not support page preloading')
return
}
if (global.__navigationHelper?.lastSuccessCallback) {
error('preload: another navigation is in progress')
return
}
if (typeof options.url !== 'string' || !options.url.trim()) {
error('preload: url must be a non-empty string')
return
}
try {
const { path, queryObj } = parseUrl(options.url, true)
if (!path) {
error('preload: url must contain a page path')
return
}
const finalPath = resolvePath(path, getBasePath(navigation)).slice(1)
navigation.preload(finalPath, queryObj)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] 预加载页会提前覆盖当前页的状态栏样式

调用 preload 后,目标页的 pageWrapperHOC → MpxNav 会挂载,而 MpxNav 无条件渲染 StatusBar。例如 A 配置 navigationBarTextStyle: 'white'、B 配置 'black',在 A 预加载 B 后,尚未跳转就会被 B 覆盖。针对性测试确认了未聚焦的 B 仍追加 StatusBar;RN 的状态栏属性按组件挂载顺序合并:https://reactnative.dev/docs/statusbar#usage-with-navigator 。

建议让状态栏配置随页面焦点生效,并覆盖多个预加载页面的场景。

} catch (e) {
error(`preload: ${e.message}`)
}
}

function navigateTo (options = {}) {
const navigationHelper = global.__navigationHelper
const navigation = getFocusedNavigation()
Expand Down Expand Up @@ -84,6 +111,17 @@ function redirectTo (options = {}) {
const res = { errMsg: `redirectTo:fail ${msg}` }
failHandle(res, options.fail, options.complete)
}
if (isPreloadSupported(navigation)) {
const state = navigation.getState()
const preloadedRoute = state.preloadedRoutes.find(route => route.name === finalPath)
if (preloadedRoute) {
navigation.reset(Object.assign({}, state, {
routes: state.routes.map((route, index) => index === state.index ? Object.assign({}, preloadedRoute, { params: queryObj }) : route),
preloadedRoutes: state.preloadedRoutes.filter(route => route.key !== preloadedRoute.key)
}))
return
}
}
navigation.replace(finalPath, queryObj)
}
}
Expand Down Expand Up @@ -147,6 +185,7 @@ function reLaunch (options = {}) {
const switchTab = envError('switchTab')

export {
preload,
redirectTo,
navigateTo,
navigateBack,
Expand Down
3 changes: 3 additions & 0 deletions packages/api-proxy/src/platform/api/route/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ const reLaunch = ENV_OBJ.reLaunch || envError('reLaunch')

const switchTab = ENV_OBJ.switchTab || envError('switchTab')

const preload = envError('preload')

export {
preload,
redirectTo,
navigateTo,
navigateBack,
Expand Down
5 changes: 4 additions & 1 deletion packages/api-proxy/src/platform/api/route/index.web.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { successHandle, failHandle, isTabBarPage, throwSSRWarning, isBrowser, resolvePath } from '../../../common/js'
import { envError, successHandle, failHandle, isTabBarPage, throwSSRWarning, isBrowser, resolvePath } from '../../../common/js'
import { EventChannel } from '../event-channel'

let routeCount = 0
Expand Down Expand Up @@ -203,7 +203,10 @@ function switchTab (options = {}) {
}
}

const preload = envError('preload')

export {
preload,
redirectTo,
navigateTo,
navigateBack,
Expand Down
Loading