Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .agents/skills/mpx2rn/references/rn-api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**:

Expand Down
10 changes: 10 additions & 0 deletions docs-vitepress/api-proxy/wxml/createIntersectionObserver.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 的测量;目标节点变化后,按需断开旧实例并在节点渲染完成后重新创建。
194 changes: 194 additions & 0 deletions packages/api-proxy/__tests__/rn/create-intersection-observer.spec.js
Original file line number Diff line number Diff line change
@@ -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()
})
})
3 changes: 2 additions & 1 deletion packages/api-proxy/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "*",
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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
Comment thread
wangshunnn marked this conversation as resolved.
this.observerRefs = null
this.relativeRef = null
this.margins = DefaultMargin
Expand All @@ -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')
Expand All @@ -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
Expand Down Expand Up @@ -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
Comment thread
wangshunnn marked this conversation as resolved.
const { intersectionRatio, intersectionRect, isInsected } = this._measureIntersection({
observeRect,
observeIndex: index,
Expand All @@ -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
}
}

Expand Down
51 changes: 51 additions & 0 deletions packages/core/__tests__/common/intersectionObserver.ios.spec.js
Original file line number Diff line number Diff line change
@@ -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({})
})
})
7 changes: 5 additions & 2 deletions packages/core/src/core/proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
}
Expand Down
Loading