-
Notifications
You must be signed in to change notification settings - Fork 392
fix(RN): enhance RN IntersectionObserver lifecycle and cleanup logic #2585
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
5a46144
fix(RN): enhance RN IntersectionObserver lifecycle and cleanup logic
wangshunnn 27b437c
fix(rn): warn when reusing disconnected intersection observers
wangshunnn 195468a
refactor(rn): simplify intersection observer disconnect cleanup
wangshunnn 63ee2e9
fix(rn): remove unnecessary throttleMeasure cancellation in disconnec…
wangshunnn 5c18b60
fix(rn): report invalid intersection observer reuse as errors
wangshunnn ab4b7c1
Merge branch 'master' into codex/fix-rn-intersection-observer-cleanup
wangshunnn a9afae5
fix(api-proxy): declare lodash as a runtime dependency
wangshunnn 1e23498
docs(rn): trim intersection observer skill guidance
wangshunnn 5b69e88
Merge branch 'master' into codex/fix-rn-intersection-observer-cleanup
wangshunnn e0add31
Merge branch 'master' into codex/fix-rn-intersection-observer-cleanup
hiyuki File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
194 changes: 194 additions & 0 deletions
194
packages/api-proxy/__tests__/rn/create-intersection-observer.spec.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
51 changes: 51 additions & 0 deletions
51
packages/core/__tests__/common/intersectionObserver.ios.spec.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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({}) | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.