diff --git a/.agents/skills/mpx2rn/references/rn-api-reference.md b/.agents/skills/mpx2rn/references/rn-api-reference.md index 2e4fb09505..25a15e2aac 100644 --- a/.agents/skills/mpx2rn/references/rn-api-reference.md +++ b/.agents/skills/mpx2rn/references/rn-api-reference.md @@ -1800,7 +1800,7 @@ 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()` | 停止观察,移除页面和组件中的注册引用,断开实例对组件的直接引用,阻止后续测量并忽略在途结果。可重复调用;断开后需新建实例才能重新观察。业务不再使用时也应释放对实例的引用,便于垃圾回收。 | `observe` 回调收到 **Object**: diff --git a/docs-vitepress/api-proxy/wxml/createIntersectionObserver.md b/docs-vitepress/api-proxy/wxml/createIntersectionObserver.md index 0bb07dfaf7..8fa3e808d0 100644 --- a/docs-vitepress/api-proxy/wxml/createIntersectionObserver.md +++ b/docs-vitepress/api-proxy/wxml/createIntersectionObserver.md @@ -25,3 +25,13 @@ ### 返回值 {#return-value} [IntersectionObserver](https://developers.weixin.qq.com/miniprogram/dev/api/wxml/IntersectionObserver.html) + +### RN 生命周期 {#rn-lifecycle} + +不再需要观察时调用 `observer.disconnect()`。该方法会解除框架对实例的登记,阻止后续测量,并忽略已发起测量的后续结果。重复调用是安全的;断开后的实例不能恢复观察,需要重新创建。业务不再使用该实例时,也应释放对它的引用,便于回收实例及其关联数据。 + +对同一实例重复调用 `observe()`,或对已断开的实例调用 `observe()`、`relativeTo()`、`relativeToViewport()`,RN 会通过 Mpx 的 `error` 报告错误并忽略调用。错误交由 `mpx.config.errorHandler` 处理,未配置时使用框架默认错误日志;该工具方法本身不主动抛出异常,因此与微信直接抛出异常的反馈方式仍有差异。 + +组件卸载时,框架会自动断开该组件创建的所有 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..fb09953128 --- /dev/null +++ b/packages/api-proxy/__tests__/rn/create-intersection-observer.spec.js @@ -0,0 +1,194 @@ +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(), errorHandler: 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([]) + expect(mpxGlobal.__mpx.config.warnHandler).not.toHaveBeenCalled() + expect(mpxGlobal.__mpx.config.errorHandler).not.toHaveBeenCalled() + }) + + 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 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) } + const callback = jest.fn() + const intersectionCtx = {} + const observer = new RNIntersectionObserver(component, {}, intersectionCtx) + + 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() + await jest.advanceTimersByTimeAsync(100) + + expect(jest.getTimerCount()).toBe(0) + expect(measure).toHaveBeenCalledTimes(measurements) + expect(callback).toHaveBeenCalledTimes(1) + expect(component.__selectRef).toHaveBeenCalledTimes(1) + expect(observer.component).toBeNull() + expect(component._intersectionObservers).toEqual([]) + expect(intersectionCtx).toEqual({}) + }) + + 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' } }, + __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 errorHandler = mpxGlobal.__mpx.config.errorHandler + expect(errorHandler).toHaveBeenCalledTimes(3) + expect(mpxGlobal.__mpx.config.warnHandler).not.toHaveBeenCalled() + ;['relativeTo', 'relativeToViewport', 'observe'].forEach((method, index) => { + expect(errorHandler).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() + }) + + 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 }) + 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..37af5e318a 100644 --- a/packages/api-proxy/package.json +++ b/packages/api-proxy/package.json @@ -38,7 +38,8 @@ "homepage": "https://github.com/didi/mpx#readme", "dependencies": { "@mpxjs/utils": "^2.11.0", - "axios": "1.17.0" + "axios": "1.17.0", + "lodash": "^4.17.15" }, "peerDependencies": { "@react-native-async-storage/async-storage": "*", 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..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, 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' @@ -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,18 @@ 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) { + error('"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') @@ -63,14 +67,22 @@ class RNIntersectionObserver { } relativeToViewport (margins = {}) { + if (this._disconnected) { + error('"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) { + 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 @@ -207,16 +219,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 +254,11 @@ class RNIntersectionObserver { } disconnect () { + if (this._disconnected) return + this._disconnected = true if (this.intersectionCtx) delete this.intersectionCtx[this.id] + remove(this.component._intersectionObservers, this) + this.component = null } } 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() }) }