From 5a46144fcb7ce7bafb88886b77ccfe424f326b64 Mon Sep 17 00:00:00 2001 From: Soon Wang Date: Wed, 9 Sep 2026 16:54:10 +0800 Subject: [PATCH 1/7] fix(RN): enhance RN IntersectionObserver lifecycle and cleanup logic --- .../mpx2rn/references/rn-api-reference.md | 4 +- .../wxml/createIntersectionObserver.md | 8 + .../rn/create-intersection-observer.spec.js | 140 ++++++++++++++++++ packages/api-proxy/package.json | 3 + .../rnIntersectionObserver.js | 29 +++- .../common/intersectionObserver.ios.spec.js | 51 +++++++ packages/core/src/core/proxy.js | 7 +- 7 files changed, 231 insertions(+), 11 deletions(-) create mode 100644 packages/api-proxy/__tests__/rn/create-intersection-observer.spec.js create mode 100644 packages/core/__tests__/common/intersectionObserver.ios.spec.js diff --git a/.agents/skills/mpx2rn/references/rn-api-reference.md b/.agents/skills/mpx2rn/references/rn-api-reference.md index bd62d9b721..c5b65ed9d6 100644 --- a/.agents/skills/mpx2rn/references/rn-api-reference.md +++ b/.agents/skills/mpx2rn/references/rn-api-reference.md @@ -1788,7 +1788,9 @@ mpx.config.rnConfig.wifiPermission = () => { | `relativeTo(selectorOrNodesRef, margins?)` | 指定参照节点;支持 selector 字符串或 `NodesRef`。`margins` 支持 `top/right/bottom/left`。 | | `relativeToViewport(margins?)` | 以当前可视窗口为参照区域。 | | `observe(selectorOrNodesRef, callback)` | 开始观察;同一实例只能调用一次。支持 selector、`NodesRef` 或 `NodesRef[]`。 | -| `disconnect()` | 从当前 IntersectionObserver 上下文移除实例。 | +| `disconnect()` | 停止观察,移除页面和组件中的注册引用,释放节点及回调引用,取消待执行的测量并忽略在途结果。可重复调用;断开后需新建实例才能重新观察。 | + +RN 组件卸载时会自动断开该组件创建的所有 observer;页面隐藏不等同于卸载,需要暂停时由业务主动断开,并在重新显示后按需创建。同一组节点应复用 observer,避免每次滚动都创建;Mpx `scroll-view` 可通过 `enable-trigger-intersection-observer` 触发现有 observer 测量。目标节点变化后,应先断开旧实例,再在节点渲染完成后重新创建。 `observe` 回调收到 **Object**: diff --git a/docs-vitepress/api-proxy/wxml/createIntersectionObserver.md b/docs-vitepress/api-proxy/wxml/createIntersectionObserver.md index 0bb07dfaf7..2ad99a429f 100644 --- a/docs-vitepress/api-proxy/wxml/createIntersectionObserver.md +++ b/docs-vitepress/api-proxy/wxml/createIntersectionObserver.md @@ -25,3 +25,11 @@ ### 返回值 {#return-value} [IntersectionObserver](https://developers.weixin.qq.com/miniprogram/dev/api/wxml/IntersectionObserver.html) + +### RN 生命周期 {#rn-lifecycle} + +不再需要观察时调用 `observer.disconnect()`。该方法会释放实例持有的组件、节点和回调引用,取消待执行的测量任务,并忽略已发起测量的后续结果。重复调用是安全的;断开后的实例不能恢复观察,需要重新创建。 + +组件卸载时,框架会自动断开该组件创建的所有 observer。页面隐藏不等同于组件卸载;需要在隐藏期间停止观察时,应主动断开,并在页面重新显示后按需创建。 + +同一组节点应复用 observer,避免在每次滚动时重复创建。使用 Mpx `scroll-view` 时,开启 `enable-trigger-intersection-observer` 可在滚动时触发现有 observer 的测量;目标节点变化后,按需断开旧实例并在节点渲染完成后重新创建。 diff --git a/packages/api-proxy/__tests__/rn/create-intersection-observer.spec.js b/packages/api-proxy/__tests__/rn/create-intersection-observer.spec.js new file mode 100644 index 0000000000..bbec764a36 --- /dev/null +++ b/packages/api-proxy/__tests__/rn/create-intersection-observer.spec.js @@ -0,0 +1,140 @@ +import { setFocusedNavigation } from '@mpxjs/utils' +import RNIntersectionObserver from '../../src/platform/api/create-intersection-observer/rnIntersectionObserver' + +jest.mock('react-native', () => ({ + Dimensions: { + get: () => ({ width: 100, height: 100 }) + } +}), { virtual: true }) + +function createNodeRef (id, measureInWindow = jest.fn(callback => callback(10, 10, 20, 20))) { + const instance = { + nodeRef: { current: { measureInWindow } }, + props: { current: { id, dataset: {} } } + } + return { getNodeInstance: () => instance } +} + +describe('RN IntersectionObserver lifecycle', () => { + beforeEach(() => { + jest.useFakeTimers() + global.mpxGlobal = { __mpx: { config: { warnHandler: jest.fn() } } } + setFocusedNavigation({ + isFocused: () => true, + layout: { top: 0, width: 100, height: 100, statusBarHeight: 0 } + }) + }) + + afterEach(() => { + jest.clearAllTimers() + jest.useRealTimers() + setFocusedNavigation(null) + delete global.mpxGlobal + }) + + it('should release registrations on repeated create and disconnect', () => { + const component = {} + const intersectionCtx = {} + const retainedObserver = new RNIntersectionObserver(component, {}, intersectionCtx) + + Array.from({ length: 1000 }).forEach(() => { + const observer = new RNIntersectionObserver(component, {}, intersectionCtx) + observer.disconnect() + observer.disconnect() + }) + + expect(Object.values(intersectionCtx)).toEqual([retainedObserver]) + expect(component._intersectionObservers).toEqual([retainedObserver]) + retainedObserver.disconnect() + expect(intersectionCtx).toEqual({}) + expect(component._intersectionObservers).toEqual([]) + }) + + it('should not retain an observer when context registration fails', () => { + const component = {} + const intersectionCtx = Object.preventExtensions({}) + + expect(() => new RNIntersectionObserver(component, {}, intersectionCtx)).toThrow(TypeError) + expect(component._intersectionObservers).toBeUndefined() + expect(intersectionCtx).toEqual({}) + }) + + it('should preserve initial and threshold-crossing callbacks', async () => { + let top = 10 + const target = createNodeRef('item', jest.fn(callback => callback(10, top, 20, 20))) + const component = { __selectRef: () => target } + const callback = jest.fn() + const observer = new RNIntersectionObserver(component, { thresholds: [0, 1] }, {}) + + observer.relativeToViewport().observe('.item', callback) + await jest.advanceTimersByTimeAsync(0) + expect(callback).toHaveBeenLastCalledWith(expect.objectContaining({ id: 'item', intersectionRatio: 1 })) + + top = 200 + observer.throttleMeasure() + await jest.advanceTimersByTimeAsync(0) + expect(callback).toHaveBeenLastCalledWith(expect.objectContaining({ id: 'item', intersectionRatio: 0 })) + expect(callback).toHaveBeenCalledTimes(2) + observer.disconnect() + }) + + it('should cancel trailing measurements and release node and callback references', async () => { + const measure = jest.fn(callback => callback(10, 10, 20, 20)) + const target = createNodeRef('item', measure) + const component = { __selectRef: jest.fn(() => target) } + const callback = jest.fn() + const observer = new RNIntersectionObserver(component, {}, {}) + + observer.relativeToViewport().observe('.item', callback) + await jest.advanceTimersByTimeAsync(0) + observer.throttleMeasure() + observer.throttleMeasure() + expect(jest.getTimerCount()).toBeGreaterThan(0) + const measurements = measure.mock.calls.length + + observer.disconnect() + expect(jest.getTimerCount()).toBe(0) + observer.relativeTo('.item').relativeToViewport().observe('.item', callback) + await jest.advanceTimersByTimeAsync(100) + + expect(measure).toHaveBeenCalledTimes(measurements) + expect(callback).toHaveBeenCalledTimes(1) + expect(component.__selectRef).toHaveBeenCalledTimes(1) + expect(observer.component).toBeNull() + expect(observer.intersectionCtx).toBeNull() + expect(observer.observerRefs).toBeNull() + expect(observer.relativeRef).toBeNull() + expect(observer.callback).not.toBe(callback) + }) + + it('should ignore native measurement results arriving after disconnect', async () => { + let finishMeasure + const target = createNodeRef('item', callback => { finishMeasure = callback }) + const component = { __selectRef: () => target } + const callback = jest.fn() + const observer = new RNIntersectionObserver(component, {}, {}) + + observer.relativeToViewport().observe('.item', callback) + observer.disconnect() + finishMeasure(10, 10, 20, 20) + await jest.advanceTimersByTimeAsync(0) + + expect(callback).not.toHaveBeenCalled() + expect(mpxGlobal.__mpx.config.warnHandler).not.toHaveBeenCalled() + }) + + it('should stop the current callback batch when disconnected from a callback', async () => { + const targets = [createNodeRef('first'), createNodeRef('second')] + const component = { __selectRef: () => targets } + const observer = new RNIntersectionObserver(component, { observeAll: true }, {}) + const callback = jest.fn(() => observer.disconnect()) + + observer.relativeToViewport().observe('.item', callback) + await jest.advanceTimersByTimeAsync(0) + + expect(callback).toHaveBeenCalledTimes(1) + expect(callback).toHaveBeenCalledWith(expect.objectContaining({ id: 'first' })) + expect(component._intersectionObservers).toEqual([]) + expect(mpxGlobal.__mpx.config.warnHandler).not.toHaveBeenCalled() + }) +}) diff --git a/packages/api-proxy/package.json b/packages/api-proxy/package.json index 5ee926e583..31127ecc5f 100644 --- a/packages/api-proxy/package.json +++ b/packages/api-proxy/package.json @@ -40,6 +40,9 @@ "@mpxjs/utils": "^2.11.0", "axios": "1.17.0" }, + "devDependencies": { + "lodash": "^4.18.1" + }, "peerDependencies": { "@react-native-async-storage/async-storage": "*", "@react-native-community/netinfo": "*", diff --git a/packages/api-proxy/src/platform/api/create-intersection-observer/rnIntersectionObserver.js b/packages/api-proxy/src/platform/api/create-intersection-observer/rnIntersectionObserver.js index 6dc3cb770f..4dd9419d9f 100644 --- a/packages/api-proxy/src/platform/api/create-intersection-observer/rnIntersectionObserver.js +++ b/packages/api-proxy/src/platform/api/create-intersection-observer/rnIntersectionObserver.js @@ -1,4 +1,4 @@ -import { isArray, isObject, isString, noop, warn } from '@mpxjs/utils' +import { isArray, isObject, isString, noop, remove, warn } from '@mpxjs/utils' import throttle from 'lodash/throttle' import { Dimensions } from 'react-native' import { getFocusedNavigation } from '../../../common/js' @@ -22,10 +22,7 @@ class RNIntersectionObserver { this.initialRatio = this.options.initialRatio this.observeAll = this.options.observeAll - // 组件上挂载对应的observers,用于在组件销毁的时候进行批量disconnect - this.component._intersectionObservers = this.component._intersectionObservers || [] - this.component._intersectionObservers.push(this) - + this._disconnected = false this.observerRefs = null this.relativeRef = null this.margins = DefaultMargin @@ -41,11 +38,15 @@ class RNIntersectionObserver { this.intersectionCtx = intersectionCtx this.intersectionCtx[this.id] = this } + // 注册成功后再挂载到组件,供组件销毁时批量disconnect + this.component._intersectionObservers = this.component._intersectionObservers || [] + this.component._intersectionObservers.push(this) return this } // 支持传递ref 或者 selector relativeTo (selector, margins = {}) { + if (this._disconnected) return this let relativeRef if (isString(selector)) { relativeRef = this.component.__selectRef(selector, 'node') @@ -63,12 +64,14 @@ class RNIntersectionObserver { } relativeToViewport (margins = {}) { + if (this._disconnected) return this this.relativeRef = WindowRefStr this.margins = Object.assign({}, DefaultMargin, margins) return this } observe (selector, callback) { + if (this._disconnected) return if (this.observerRefs) { warn('"observe" call can be only called once in IntersectionObserver', this.mpxFileResource) return @@ -207,16 +210,16 @@ class RNIntersectionObserver { // 计算节点的rect信息 _measureTarget (isInit = false) { - if (!this.observerRefs || !this.relativeRef) { + if (this._disconnected || !this.observerRefs || !this.relativeRef) { return } Promise.all([ this._getReferenceRect(this.observerRefs), this._getReferenceRect(this.relativeRef) ]).then(([observeRects, relativeRect]) => { - if (relativeRect === IgnoreTarget) return + if (this._disconnected || relativeRect === IgnoreTarget) return observeRects.forEach((observeRect, index) => { - if (observeRect === IgnoreTarget) return + if (this._disconnected || observeRect === IgnoreTarget) return const { intersectionRatio, intersectionRect, isInsected } = this._measureIntersection({ observeRect, observeIndex: index, @@ -242,7 +245,17 @@ class RNIntersectionObserver { } disconnect () { + if (this._disconnected) return + this._disconnected = true + this.throttleMeasure.cancel() if (this.intersectionCtx) delete this.intersectionCtx[this.id] + remove(this.component._intersectionObservers, this) + this.component = null + this.intersectionCtx = null + this.observerRefs = null + this.relativeRef = null + this.callback = noop + this.previousIntersectionRatio = [] } } diff --git a/packages/core/__tests__/common/intersectionObserver.ios.spec.js b/packages/core/__tests__/common/intersectionObserver.ios.spec.js new file mode 100644 index 0000000000..3c64f92440 --- /dev/null +++ b/packages/core/__tests__/common/intersectionObserver.ios.spec.js @@ -0,0 +1,51 @@ +global.__mpx_mode__ = 'ios' +global.__mpx_perf_framework__ = false +global.__mpx_dynamic_runtime__ = false +global.mpxGlobal = {} + +jest.mock('@mpxjs/perf', () => ({}), { virtual: true }) +jest.mock('react-native', () => ({}), { virtual: true }) + +// 初始化Mpx后再加载proxy,保持运行时的模块初始化顺序。 +require('../../src') +const MpxProxy = require('../../src/core/proxy').default +const { BEFOREUNMOUNT, UNMOUNTED } = require('../../src/core/innerLifecycle') +const RNIntersectionObserver = require('../../../api-proxy/src/platform/api/create-intersection-observer/rnIntersectionObserver').default + +describe('RN component intersection observers', () => { + it('should disconnect every owned observer on unmount without affecting other components', () => { + const component = {} + const proxy = new MpxProxy({}, component) + const intersectionCtx = {} + const otherObserver = new RNIntersectionObserver({}, {}, intersectionCtx) + const observers = Array.from({ length: 3 }, () => new RNIntersectionObserver(component, {}, intersectionCtx)) + + proxy.unmounted() + + expect(component._intersectionObservers).toEqual([]) + expect(Object.values(intersectionCtx)).toEqual([otherObserver]) + observers.forEach(observer => { + expect(observer.component).toBeNull() + }) + expect(proxy.isUnmounted()).toBe(true) + otherObserver.disconnect() + }) + + it('should tolerate manual disconnect in unmount hooks', () => { + const component = {} + const intersectionCtx = {} + const first = new RNIntersectionObserver(component, {}, intersectionCtx) + const second = new RNIntersectionObserver(component, {}, intersectionCtx) + const unmounted = jest.fn(() => second.disconnect()) + const proxy = new MpxProxy({ + [BEFOREUNMOUNT]: () => first.disconnect(), + [UNMOUNTED]: unmounted + }, component) + + proxy.unmounted() + + expect(unmounted).toHaveBeenCalledTimes(1) + expect(component._intersectionObservers).toEqual([]) + expect(intersectionCtx).toEqual({}) + }) +}) diff --git a/packages/core/src/core/proxy.js b/packages/core/src/core/proxy.js index 814182a456..0bec81723c 100644 --- a/packages/core/src/core/proxy.js +++ b/packages/core/src/core/proxy.js @@ -261,8 +261,11 @@ export default class MpxProxy { if (__mpx_perf_framework__) perfId = perf.scopeStart('instance:unmount') this.scope?.stop() if (this.update) this.update.active = false - if (this._intersectionObservers) { - this._intersectionObservers.forEach((observer) => { + const intersectionObservers = this.target._intersectionObservers + if (intersectionObservers?.length) { + // 先清空组件列表,避免disconnect逐项删除时漏项或反复移动数组元素 + this.target._intersectionObservers = [] + intersectionObservers.forEach((observer) => { observer.disconnect() }) } From 27b437c1b2e94df9f2b58a6620db2765ae021809 Mon Sep 17 00:00:00 2001 From: Soon Wang Date: Fri, 11 Sep 2026 16:19:43 +0800 Subject: [PATCH 2/7] fix(rn): warn when reusing disconnected intersection observers --- .../mpx2rn/references/rn-api-reference.md | 2 ++ .../wxml/createIntersectionObserver.md | 2 ++ .../rn/create-intersection-observer.spec.js | 36 ++++++++++++++++++- .../rnIntersectionObserver.js | 15 ++++++-- 4 files changed, 51 insertions(+), 4 deletions(-) diff --git a/.agents/skills/mpx2rn/references/rn-api-reference.md b/.agents/skills/mpx2rn/references/rn-api-reference.md index c5b65ed9d6..dbef9b2a46 100644 --- a/.agents/skills/mpx2rn/references/rn-api-reference.md +++ b/.agents/skills/mpx2rn/references/rn-api-reference.md @@ -1790,6 +1790,8 @@ mpx.config.rnConfig.wifiPermission = () => { | `observe(selectorOrNodesRef, callback)` | 开始观察;同一实例只能调用一次。支持 selector、`NodesRef` 或 `NodesRef[]`。 | | `disconnect()` | 停止观察,移除页面和组件中的注册引用,释放节点及回调引用,取消待执行的测量并忽略在途结果。可重复调用;断开后需新建实例才能重新观察。 | +对已断开的实例再次调用 `observe()`、`relativeTo()` 或 `relativeToViewport()`,RN 沿用 `warn + return`:通过 Mpx 的警告机制提示并忽略调用,不主动抛出异常。不要将此错误反馈方式视为与微信完全一致;微信在已观察实例上重新调用 `observe()` 或 `relativeTo()` 会抛出异常。 + RN 组件卸载时会自动断开该组件创建的所有 observer;页面隐藏不等同于卸载,需要暂停时由业务主动断开,并在重新显示后按需创建。同一组节点应复用 observer,避免每次滚动都创建;Mpx `scroll-view` 可通过 `enable-trigger-intersection-observer` 触发现有 observer 测量。目标节点变化后,应先断开旧实例,再在节点渲染完成后重新创建。 `observe` 回调收到 **Object**: diff --git a/docs-vitepress/api-proxy/wxml/createIntersectionObserver.md b/docs-vitepress/api-proxy/wxml/createIntersectionObserver.md index 2ad99a429f..83835ba4da 100644 --- a/docs-vitepress/api-proxy/wxml/createIntersectionObserver.md +++ b/docs-vitepress/api-proxy/wxml/createIntersectionObserver.md @@ -30,6 +30,8 @@ 不再需要观察时调用 `observer.disconnect()`。该方法会释放实例持有的组件、节点和回调引用,取消待执行的测量任务,并忽略已发起测量的后续结果。重复调用是安全的;断开后的实例不能恢复观察,需要重新创建。 +对已断开的实例调用 `observe()`、`relativeTo()` 或 `relativeToViewport()`,RN 会通过 Mpx 的 `warn` 提示并忽略调用,沿用现有 RN 错误处理方式,不主动抛出异常。这与微信在已观察实例上重新调用 `observe()` 或 `relativeTo()` 时抛出异常的反馈方式不同。 + 组件卸载时,框架会自动断开该组件创建的所有 observer。页面隐藏不等同于组件卸载;需要在隐藏期间停止观察时,应主动断开,并在页面重新显示后按需创建。 同一组节点应复用 observer,避免在每次滚动时重复创建。使用 Mpx `scroll-view` 时,开启 `enable-trigger-intersection-observer` 可在滚动时触发现有 observer 的测量;目标节点变化后,按需断开旧实例并在节点渲染完成后重新创建。 diff --git a/packages/api-proxy/__tests__/rn/create-intersection-observer.spec.js b/packages/api-proxy/__tests__/rn/create-intersection-observer.spec.js index bbec764a36..78c98cb8d9 100644 --- a/packages/api-proxy/__tests__/rn/create-intersection-observer.spec.js +++ b/packages/api-proxy/__tests__/rn/create-intersection-observer.spec.js @@ -48,6 +48,7 @@ describe('RN IntersectionObserver lifecycle', () => { retainedObserver.disconnect() expect(intersectionCtx).toEqual({}) expect(component._intersectionObservers).toEqual([]) + expect(mpxGlobal.__mpx.config.warnHandler).not.toHaveBeenCalled() }) it('should not retain an observer when context registration fails', () => { @@ -94,7 +95,6 @@ describe('RN IntersectionObserver lifecycle', () => { observer.disconnect() expect(jest.getTimerCount()).toBe(0) - observer.relativeTo('.item').relativeToViewport().observe('.item', callback) await jest.advanceTimersByTimeAsync(100) expect(measure).toHaveBeenCalledTimes(measurements) @@ -107,6 +107,40 @@ describe('RN IntersectionObserver lifecycle', () => { expect(observer.callback).not.toBe(callback) }) + it('should warn without throwing or restarting observation when a disconnected observer is reused', async () => { + const measure = jest.fn(callback => callback(10, 10, 20, 20)) + const component = { + __mpxProxy: { options: { mpxFileResource: 'observer-test.mpx' } }, + __selectRef: jest.fn(() => createNodeRef('item', measure)) + } + const callback = jest.fn() + const reuseCallback = jest.fn() + const observer = new RNIntersectionObserver(component, {}, {}) + + observer.relativeToViewport().observe('.item', callback) + await jest.advanceTimersByTimeAsync(0) + observer.disconnect() + + expect(observer.relativeTo('.item')).toBe(observer) + expect(observer.relativeToViewport()).toBe(observer) + expect(observer.observe('.item', reuseCallback)).toBeUndefined() + await jest.advanceTimersByTimeAsync(100) + + const warnHandler = mpxGlobal.__mpx.config.warnHandler + expect(warnHandler).toHaveBeenCalledTimes(3) + ;['relativeTo', 'relativeToViewport', 'observe'].forEach((method, index) => { + expect(warnHandler).toHaveBeenNthCalledWith(index + 1, + `"${method}" cannot be called after disconnect in IntersectionObserver. Please create a new observer.`, + 'observer-test.mpx', expect.any(Error)) + }) + expect(component.__selectRef).toHaveBeenCalledTimes(1) + expect(measure).toHaveBeenCalledTimes(1) + expect(callback).toHaveBeenCalledTimes(1) + expect(reuseCallback).not.toHaveBeenCalled() + expect(observer.observerRefs).toBeNull() + expect(observer.relativeRef).toBeNull() + }) + it('should ignore native measurement results arriving after disconnect', async () => { let finishMeasure const target = createNodeRef('item', callback => { finishMeasure = callback }) diff --git a/packages/api-proxy/src/platform/api/create-intersection-observer/rnIntersectionObserver.js b/packages/api-proxy/src/platform/api/create-intersection-observer/rnIntersectionObserver.js index 4dd9419d9f..9c5fbd5d63 100644 --- a/packages/api-proxy/src/platform/api/create-intersection-observer/rnIntersectionObserver.js +++ b/packages/api-proxy/src/platform/api/create-intersection-observer/rnIntersectionObserver.js @@ -46,7 +46,10 @@ class RNIntersectionObserver { // 支持传递ref 或者 selector relativeTo (selector, margins = {}) { - if (this._disconnected) return this + if (this._disconnected) { + warn('"relativeTo" cannot be called after disconnect in IntersectionObserver. Please create a new observer.', this.mpxFileResource) + return this + } let relativeRef if (isString(selector)) { relativeRef = this.component.__selectRef(selector, 'node') @@ -64,14 +67,20 @@ class RNIntersectionObserver { } relativeToViewport (margins = {}) { - if (this._disconnected) return this + if (this._disconnected) { + warn('"relativeToViewport" cannot be called after disconnect in IntersectionObserver. Please create a new observer.', this.mpxFileResource) + return this + } this.relativeRef = WindowRefStr this.margins = Object.assign({}, DefaultMargin, margins) return this } observe (selector, callback) { - if (this._disconnected) return + if (this._disconnected) { + warn('"observe" cannot be called after disconnect in IntersectionObserver. Please create a new observer.', this.mpxFileResource) + return + } if (this.observerRefs) { warn('"observe" call can be only called once in IntersectionObserver', this.mpxFileResource) return From 195468a82d49772723d7c62f4342f9ced6cc053a Mon Sep 17 00:00:00 2001 From: Soon Wang Date: Fri, 11 Sep 2026 16:23:53 +0800 Subject: [PATCH 3/7] refactor(rn): simplify intersection observer disconnect cleanup --- .../skills/mpx2rn/references/rn-api-reference.md | 2 +- .../api-proxy/wxml/createIntersectionObserver.md | 2 +- .../rn/create-intersection-observer.spec.js | 13 +++++-------- .../rnIntersectionObserver.js | 5 ----- 4 files changed, 7 insertions(+), 15 deletions(-) diff --git a/.agents/skills/mpx2rn/references/rn-api-reference.md b/.agents/skills/mpx2rn/references/rn-api-reference.md index dbef9b2a46..f99fc95c7b 100644 --- a/.agents/skills/mpx2rn/references/rn-api-reference.md +++ b/.agents/skills/mpx2rn/references/rn-api-reference.md @@ -1788,7 +1788,7 @@ mpx.config.rnConfig.wifiPermission = () => { | `relativeTo(selectorOrNodesRef, margins?)` | 指定参照节点;支持 selector 字符串或 `NodesRef`。`margins` 支持 `top/right/bottom/left`。 | | `relativeToViewport(margins?)` | 以当前可视窗口为参照区域。 | | `observe(selectorOrNodesRef, callback)` | 开始观察;同一实例只能调用一次。支持 selector、`NodesRef` 或 `NodesRef[]`。 | -| `disconnect()` | 停止观察,移除页面和组件中的注册引用,释放节点及回调引用,取消待执行的测量并忽略在途结果。可重复调用;断开后需新建实例才能重新观察。 | +| `disconnect()` | 停止观察,移除页面和组件中的注册引用,断开实例对组件的直接引用,取消待执行的测量并忽略在途结果。可重复调用;断开后需新建实例才能重新观察。业务不再使用时也应释放对实例的引用,便于垃圾回收。 | 对已断开的实例再次调用 `observe()`、`relativeTo()` 或 `relativeToViewport()`,RN 沿用 `warn + return`:通过 Mpx 的警告机制提示并忽略调用,不主动抛出异常。不要将此错误反馈方式视为与微信完全一致;微信在已观察实例上重新调用 `observe()` 或 `relativeTo()` 会抛出异常。 diff --git a/docs-vitepress/api-proxy/wxml/createIntersectionObserver.md b/docs-vitepress/api-proxy/wxml/createIntersectionObserver.md index 83835ba4da..1b3f3ddd5d 100644 --- a/docs-vitepress/api-proxy/wxml/createIntersectionObserver.md +++ b/docs-vitepress/api-proxy/wxml/createIntersectionObserver.md @@ -28,7 +28,7 @@ ### RN 生命周期 {#rn-lifecycle} -不再需要观察时调用 `observer.disconnect()`。该方法会释放实例持有的组件、节点和回调引用,取消待执行的测量任务,并忽略已发起测量的后续结果。重复调用是安全的;断开后的实例不能恢复观察,需要重新创建。 +不再需要观察时调用 `observer.disconnect()`。该方法会解除框架对实例的登记,取消待执行的测量任务,并忽略已发起测量的后续结果。重复调用是安全的;断开后的实例不能恢复观察,需要重新创建。业务不再使用该实例时,也应释放对它的引用,便于回收实例及其关联数据。 对已断开的实例调用 `observe()`、`relativeTo()` 或 `relativeToViewport()`,RN 会通过 Mpx 的 `warn` 提示并忽略调用,沿用现有 RN 错误处理方式,不主动抛出异常。这与微信在已观察实例上重新调用 `observe()` 或 `relativeTo()` 时抛出异常的反馈方式不同。 diff --git a/packages/api-proxy/__tests__/rn/create-intersection-observer.spec.js b/packages/api-proxy/__tests__/rn/create-intersection-observer.spec.js index 78c98cb8d9..05cc7ade80 100644 --- a/packages/api-proxy/__tests__/rn/create-intersection-observer.spec.js +++ b/packages/api-proxy/__tests__/rn/create-intersection-observer.spec.js @@ -79,12 +79,13 @@ describe('RN IntersectionObserver lifecycle', () => { observer.disconnect() }) - it('should cancel trailing measurements and release node and callback references', async () => { + it('should cancel trailing measurements and remove observer registrations', async () => { const measure = jest.fn(callback => callback(10, 10, 20, 20)) const target = createNodeRef('item', measure) const component = { __selectRef: jest.fn(() => target) } const callback = jest.fn() - const observer = new RNIntersectionObserver(component, {}, {}) + const intersectionCtx = {} + const observer = new RNIntersectionObserver(component, {}, intersectionCtx) observer.relativeToViewport().observe('.item', callback) await jest.advanceTimersByTimeAsync(0) @@ -101,10 +102,8 @@ describe('RN IntersectionObserver lifecycle', () => { expect(callback).toHaveBeenCalledTimes(1) expect(component.__selectRef).toHaveBeenCalledTimes(1) expect(observer.component).toBeNull() - expect(observer.intersectionCtx).toBeNull() - expect(observer.observerRefs).toBeNull() - expect(observer.relativeRef).toBeNull() - expect(observer.callback).not.toBe(callback) + expect(component._intersectionObservers).toEqual([]) + expect(intersectionCtx).toEqual({}) }) it('should warn without throwing or restarting observation when a disconnected observer is reused', async () => { @@ -137,8 +136,6 @@ describe('RN IntersectionObserver lifecycle', () => { expect(measure).toHaveBeenCalledTimes(1) expect(callback).toHaveBeenCalledTimes(1) expect(reuseCallback).not.toHaveBeenCalled() - expect(observer.observerRefs).toBeNull() - expect(observer.relativeRef).toBeNull() }) it('should ignore native measurement results arriving after disconnect', async () => { diff --git a/packages/api-proxy/src/platform/api/create-intersection-observer/rnIntersectionObserver.js b/packages/api-proxy/src/platform/api/create-intersection-observer/rnIntersectionObserver.js index 9c5fbd5d63..4eb9ecb6f4 100644 --- a/packages/api-proxy/src/platform/api/create-intersection-observer/rnIntersectionObserver.js +++ b/packages/api-proxy/src/platform/api/create-intersection-observer/rnIntersectionObserver.js @@ -260,11 +260,6 @@ class RNIntersectionObserver { if (this.intersectionCtx) delete this.intersectionCtx[this.id] remove(this.component._intersectionObservers, this) this.component = null - this.intersectionCtx = null - this.observerRefs = null - this.relativeRef = null - this.callback = noop - this.previousIntersectionRatio = [] } } From 63ee2e9d8cffd736ed49ad2f90b9ee1ccd0dae4c Mon Sep 17 00:00:00 2001 From: Soon Wang Date: Fri, 11 Sep 2026 17:00:18 +0800 Subject: [PATCH 4/7] fix(rn): remove unnecessary throttleMeasure cancellation in disconnect method --- .../api/create-intersection-observer/rnIntersectionObserver.js | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/api-proxy/src/platform/api/create-intersection-observer/rnIntersectionObserver.js b/packages/api-proxy/src/platform/api/create-intersection-observer/rnIntersectionObserver.js index 4eb9ecb6f4..32e23fb088 100644 --- a/packages/api-proxy/src/platform/api/create-intersection-observer/rnIntersectionObserver.js +++ b/packages/api-proxy/src/platform/api/create-intersection-observer/rnIntersectionObserver.js @@ -256,7 +256,6 @@ class RNIntersectionObserver { disconnect () { if (this._disconnected) return this._disconnected = true - this.throttleMeasure.cancel() if (this.intersectionCtx) delete this.intersectionCtx[this.id] remove(this.component._intersectionObservers, this) this.component = null From 5c18b60d84250cd8d99b3c4e956cd00c9ffccc2c Mon Sep 17 00:00:00 2001 From: Soon Wang Date: Fri, 11 Sep 2026 17:01:42 +0800 Subject: [PATCH 5/7] fix(rn): report invalid intersection observer reuse as errors --- .../mpx2rn/references/rn-api-reference.md | 4 +- .../wxml/createIntersectionObserver.md | 4 +- .../rn/create-intersection-observer.spec.js | 37 +++++++++++++++---- .../rnIntersectionObserver.js | 10 ++--- 4 files changed, 39 insertions(+), 16 deletions(-) diff --git a/.agents/skills/mpx2rn/references/rn-api-reference.md b/.agents/skills/mpx2rn/references/rn-api-reference.md index f99fc95c7b..c389ea486a 100644 --- a/.agents/skills/mpx2rn/references/rn-api-reference.md +++ b/.agents/skills/mpx2rn/references/rn-api-reference.md @@ -1788,9 +1788,9 @@ mpx.config.rnConfig.wifiPermission = () => { | `relativeTo(selectorOrNodesRef, margins?)` | 指定参照节点;支持 selector 字符串或 `NodesRef`。`margins` 支持 `top/right/bottom/left`。 | | `relativeToViewport(margins?)` | 以当前可视窗口为参照区域。 | | `observe(selectorOrNodesRef, callback)` | 开始观察;同一实例只能调用一次。支持 selector、`NodesRef` 或 `NodesRef[]`。 | -| `disconnect()` | 停止观察,移除页面和组件中的注册引用,断开实例对组件的直接引用,取消待执行的测量并忽略在途结果。可重复调用;断开后需新建实例才能重新观察。业务不再使用时也应释放对实例的引用,便于垃圾回收。 | +| `disconnect()` | 停止观察,移除页面和组件中的注册引用,断开实例对组件的直接引用,阻止后续测量并忽略在途结果。可重复调用;断开后需新建实例才能重新观察。业务不再使用时也应释放对实例的引用,便于垃圾回收。 | -对已断开的实例再次调用 `observe()`、`relativeTo()` 或 `relativeToViewport()`,RN 沿用 `warn + return`:通过 Mpx 的警告机制提示并忽略调用,不主动抛出异常。不要将此错误反馈方式视为与微信完全一致;微信在已观察实例上重新调用 `observe()` 或 `relativeTo()` 会抛出异常。 +同一实例重复调用 `observe()`,或对已断开的实例再次调用 `observe()`、`relativeTo()`、`relativeToViewport()`,RN 使用 `error + return`:通过 Mpx 的 `error` 报告错误并忽略调用,交由 `mpx.config.errorHandler` 或框架默认错误日志处理。`error` 本身不主动抛出异常,不等同于微信直接抛出异常的反馈方式。 RN 组件卸载时会自动断开该组件创建的所有 observer;页面隐藏不等同于卸载,需要暂停时由业务主动断开,并在重新显示后按需创建。同一组节点应复用 observer,避免每次滚动都创建;Mpx `scroll-view` 可通过 `enable-trigger-intersection-observer` 触发现有 observer 测量。目标节点变化后,应先断开旧实例,再在节点渲染完成后重新创建。 diff --git a/docs-vitepress/api-proxy/wxml/createIntersectionObserver.md b/docs-vitepress/api-proxy/wxml/createIntersectionObserver.md index 1b3f3ddd5d..8fa3e808d0 100644 --- a/docs-vitepress/api-proxy/wxml/createIntersectionObserver.md +++ b/docs-vitepress/api-proxy/wxml/createIntersectionObserver.md @@ -28,9 +28,9 @@ ### RN 生命周期 {#rn-lifecycle} -不再需要观察时调用 `observer.disconnect()`。该方法会解除框架对实例的登记,取消待执行的测量任务,并忽略已发起测量的后续结果。重复调用是安全的;断开后的实例不能恢复观察,需要重新创建。业务不再使用该实例时,也应释放对它的引用,便于回收实例及其关联数据。 +不再需要观察时调用 `observer.disconnect()`。该方法会解除框架对实例的登记,阻止后续测量,并忽略已发起测量的后续结果。重复调用是安全的;断开后的实例不能恢复观察,需要重新创建。业务不再使用该实例时,也应释放对它的引用,便于回收实例及其关联数据。 -对已断开的实例调用 `observe()`、`relativeTo()` 或 `relativeToViewport()`,RN 会通过 Mpx 的 `warn` 提示并忽略调用,沿用现有 RN 错误处理方式,不主动抛出异常。这与微信在已观察实例上重新调用 `observe()` 或 `relativeTo()` 时抛出异常的反馈方式不同。 +对同一实例重复调用 `observe()`,或对已断开的实例调用 `observe()`、`relativeTo()`、`relativeToViewport()`,RN 会通过 Mpx 的 `error` 报告错误并忽略调用。错误交由 `mpx.config.errorHandler` 处理,未配置时使用框架默认错误日志;该工具方法本身不主动抛出异常,因此与微信直接抛出异常的反馈方式仍有差异。 组件卸载时,框架会自动断开该组件创建的所有 observer。页面隐藏不等同于组件卸载;需要在隐藏期间停止观察时,应主动断开,并在页面重新显示后按需创建。 diff --git a/packages/api-proxy/__tests__/rn/create-intersection-observer.spec.js b/packages/api-proxy/__tests__/rn/create-intersection-observer.spec.js index 05cc7ade80..fb09953128 100644 --- a/packages/api-proxy/__tests__/rn/create-intersection-observer.spec.js +++ b/packages/api-proxy/__tests__/rn/create-intersection-observer.spec.js @@ -18,7 +18,7 @@ function createNodeRef (id, measureInWindow = jest.fn(callback => callback(10, 1 describe('RN IntersectionObserver lifecycle', () => { beforeEach(() => { jest.useFakeTimers() - global.mpxGlobal = { __mpx: { config: { warnHandler: jest.fn() } } } + global.mpxGlobal = { __mpx: { config: { warnHandler: jest.fn(), errorHandler: jest.fn() } } } setFocusedNavigation({ isFocused: () => true, layout: { top: 0, width: 100, height: 100, statusBarHeight: 0 } @@ -49,6 +49,7 @@ describe('RN IntersectionObserver lifecycle', () => { expect(intersectionCtx).toEqual({}) expect(component._intersectionObservers).toEqual([]) expect(mpxGlobal.__mpx.config.warnHandler).not.toHaveBeenCalled() + expect(mpxGlobal.__mpx.config.errorHandler).not.toHaveBeenCalled() }) it('should not retain an observer when context registration fails', () => { @@ -79,7 +80,7 @@ describe('RN IntersectionObserver lifecycle', () => { observer.disconnect() }) - it('should cancel trailing measurements and remove observer registrations', async () => { + it('should prevent trailing measurements and remove observer registrations', async () => { const measure = jest.fn(callback => callback(10, 10, 20, 20)) const target = createNodeRef('item', measure) const component = { __selectRef: jest.fn(() => target) } @@ -95,9 +96,9 @@ describe('RN IntersectionObserver lifecycle', () => { const measurements = measure.mock.calls.length observer.disconnect() - expect(jest.getTimerCount()).toBe(0) await jest.advanceTimersByTimeAsync(100) + expect(jest.getTimerCount()).toBe(0) expect(measure).toHaveBeenCalledTimes(measurements) expect(callback).toHaveBeenCalledTimes(1) expect(component.__selectRef).toHaveBeenCalledTimes(1) @@ -106,7 +107,7 @@ describe('RN IntersectionObserver lifecycle', () => { expect(intersectionCtx).toEqual({}) }) - it('should warn without throwing or restarting observation when a disconnected observer is reused', async () => { + it('should report errors without throwing or restarting observation when a disconnected observer is reused', async () => { const measure = jest.fn(callback => callback(10, 10, 20, 20)) const component = { __mpxProxy: { options: { mpxFileResource: 'observer-test.mpx' } }, @@ -125,10 +126,11 @@ describe('RN IntersectionObserver lifecycle', () => { expect(observer.observe('.item', reuseCallback)).toBeUndefined() await jest.advanceTimersByTimeAsync(100) - const warnHandler = mpxGlobal.__mpx.config.warnHandler - expect(warnHandler).toHaveBeenCalledTimes(3) + const errorHandler = mpxGlobal.__mpx.config.errorHandler + expect(errorHandler).toHaveBeenCalledTimes(3) + expect(mpxGlobal.__mpx.config.warnHandler).not.toHaveBeenCalled() ;['relativeTo', 'relativeToViewport', 'observe'].forEach((method, index) => { - expect(warnHandler).toHaveBeenNthCalledWith(index + 1, + expect(errorHandler).toHaveBeenNthCalledWith(index + 1, `"${method}" cannot be called after disconnect in IntersectionObserver. Please create a new observer.`, 'observer-test.mpx', expect.any(Error)) }) @@ -138,6 +140,27 @@ describe('RN IntersectionObserver lifecycle', () => { expect(reuseCallback).not.toHaveBeenCalled() }) + it('should report repeated observe calls without replacing the original callback', async () => { + const target = createNodeRef('item') + const component = { __selectRef: jest.fn(() => target) } + const callback = jest.fn() + const repeatedCallback = jest.fn() + const observer = new RNIntersectionObserver(component, {}, {}) + + observer.relativeToViewport().observe('.item', callback) + observer.observe('.item', repeatedCallback) + await jest.advanceTimersByTimeAsync(0) + + expect(mpxGlobal.__mpx.config.errorHandler).toHaveBeenCalledTimes(1) + expect(mpxGlobal.__mpx.config.errorHandler).toHaveBeenCalledWith( + '"observe" call can be only called once in IntersectionObserver', '', expect.any(Error)) + expect(mpxGlobal.__mpx.config.warnHandler).not.toHaveBeenCalled() + expect(component.__selectRef).toHaveBeenCalledTimes(1) + expect(callback).toHaveBeenCalledTimes(1) + expect(repeatedCallback).not.toHaveBeenCalled() + observer.disconnect() + }) + it('should ignore native measurement results arriving after disconnect', async () => { let finishMeasure const target = createNodeRef('item', callback => { finishMeasure = callback }) diff --git a/packages/api-proxy/src/platform/api/create-intersection-observer/rnIntersectionObserver.js b/packages/api-proxy/src/platform/api/create-intersection-observer/rnIntersectionObserver.js index 32e23fb088..f66dca4696 100644 --- a/packages/api-proxy/src/platform/api/create-intersection-observer/rnIntersectionObserver.js +++ b/packages/api-proxy/src/platform/api/create-intersection-observer/rnIntersectionObserver.js @@ -1,4 +1,4 @@ -import { isArray, isObject, isString, noop, remove, warn } from '@mpxjs/utils' +import { error, isArray, isObject, isString, noop, remove, warn } from '@mpxjs/utils' import throttle from 'lodash/throttle' import { Dimensions } from 'react-native' import { getFocusedNavigation } from '../../../common/js' @@ -47,7 +47,7 @@ class RNIntersectionObserver { // 支持传递ref 或者 selector relativeTo (selector, margins = {}) { if (this._disconnected) { - warn('"relativeTo" cannot be called after disconnect in IntersectionObserver. Please create a new observer.', this.mpxFileResource) + error('"relativeTo" cannot be called after disconnect in IntersectionObserver. Please create a new observer.', this.mpxFileResource) return this } let relativeRef @@ -68,7 +68,7 @@ class RNIntersectionObserver { relativeToViewport (margins = {}) { if (this._disconnected) { - warn('"relativeToViewport" cannot be called after disconnect in IntersectionObserver. Please create a new observer.', this.mpxFileResource) + error('"relativeToViewport" cannot be called after disconnect in IntersectionObserver. Please create a new observer.', this.mpxFileResource) return this } this.relativeRef = WindowRefStr @@ -78,11 +78,11 @@ class RNIntersectionObserver { observe (selector, callback) { if (this._disconnected) { - warn('"observe" cannot be called after disconnect in IntersectionObserver. Please create a new observer.', this.mpxFileResource) + error('"observe" cannot be called after disconnect in IntersectionObserver. Please create a new observer.', this.mpxFileResource) return } if (this.observerRefs) { - warn('"observe" call can be only called once in IntersectionObserver', this.mpxFileResource) + error('"observe" call can be only called once in IntersectionObserver', this.mpxFileResource) return } let targetRef = null From a9afae5db9284ee10e2ee9b1db36cb571addf43c Mon Sep 17 00:00:00 2001 From: Soon Wang Date: Mon, 14 Sep 2026 15:00:34 +0800 Subject: [PATCH 6/7] fix(api-proxy): declare lodash as a runtime dependency --- packages/api-proxy/package.json | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/api-proxy/package.json b/packages/api-proxy/package.json index 31127ecc5f..37af5e318a 100644 --- a/packages/api-proxy/package.json +++ b/packages/api-proxy/package.json @@ -38,10 +38,8 @@ "homepage": "https://github.com/didi/mpx#readme", "dependencies": { "@mpxjs/utils": "^2.11.0", - "axios": "1.17.0" - }, - "devDependencies": { - "lodash": "^4.18.1" + "axios": "1.17.0", + "lodash": "^4.17.15" }, "peerDependencies": { "@react-native-async-storage/async-storage": "*", From 1e23498436a0f77d959061a2535117b3ef4f044a Mon Sep 17 00:00:00 2001 From: Soon Wang Date: Mon, 14 Sep 2026 15:04:06 +0800 Subject: [PATCH 7/7] docs(rn): trim intersection observer skill guidance --- .agents/skills/mpx2rn/references/rn-api-reference.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.agents/skills/mpx2rn/references/rn-api-reference.md b/.agents/skills/mpx2rn/references/rn-api-reference.md index 786eaabd18..25a15e2aac 100644 --- a/.agents/skills/mpx2rn/references/rn-api-reference.md +++ b/.agents/skills/mpx2rn/references/rn-api-reference.md @@ -1802,10 +1802,6 @@ mpx.config.rnConfig.wifiPermission = () => { | `observe(selectorOrNodesRef, callback)` | 开始观察;同一实例只能调用一次。支持 selector、`NodesRef` 或 `NodesRef[]`。 | | `disconnect()` | 停止观察,移除页面和组件中的注册引用,断开实例对组件的直接引用,阻止后续测量并忽略在途结果。可重复调用;断开后需新建实例才能重新观察。业务不再使用时也应释放对实例的引用,便于垃圾回收。 | -同一实例重复调用 `observe()`,或对已断开的实例再次调用 `observe()`、`relativeTo()`、`relativeToViewport()`,RN 使用 `error + return`:通过 Mpx 的 `error` 报告错误并忽略调用,交由 `mpx.config.errorHandler` 或框架默认错误日志处理。`error` 本身不主动抛出异常,不等同于微信直接抛出异常的反馈方式。 - -RN 组件卸载时会自动断开该组件创建的所有 observer;页面隐藏不等同于卸载,需要暂停时由业务主动断开,并在重新显示后按需创建。同一组节点应复用 observer,避免每次滚动都创建;Mpx `scroll-view` 可通过 `enable-trigger-intersection-observer` 触发现有 observer 测量。目标节点变化后,应先断开旧实例,再在节点渲染完成后重新创建。 - `observe` 回调收到 **Object**: | 字段名 | 类型 | 说明 |