diff --git a/.changeset/tender-radios-lose.md b/.changeset/tender-radios-lose.md new file mode 100644 index 0000000000..84cbaaaa53 --- /dev/null +++ b/.changeset/tender-radios-lose.md @@ -0,0 +1,5 @@ +--- +"@lynx-js/react": patch +--- + +Fix memory leak by clearing list callbacks when __DestroyLifetime event is triggered. diff --git a/packages/react/runtime/__test__/list.test.jsx b/packages/react/runtime/__test__/list.test.jsx index 72e7279599..b08ccbb2cc 100644 --- a/packages/react/runtime/__test__/list.test.jsx +++ b/packages/react/runtime/__test__/list.test.jsx @@ -4380,3 +4380,33 @@ describe('update-list-info profile', () => { `); }); }); + +describe('clear __UpdateListCallbacks', () => { + it('should register __DestroyLifetime listener and clear callbacks when triggered', () => { + const s1 = __SNAPSHOT__( + + test + {HOLE} + , + ); + + const a = new SnapshotInstance(s1); + a.ensureElements(); + + expect(lynx.getNative().addEventListener).toHaveBeenCalledWith( + '__DestroyLifetime', + expect.any(Function), + ); + + const listElement = a.__elements[3]; + expect(listElement.componentAtIndex).not.toBeNull(); + expect(listElement.enqueueComponent).not.toBeNull(); + expect(listElement.componentAtIndexes).not.toBeNull(); + + lynx.getNative().dispatchEvent({ type: '__DestroyLifetime', data: {} }); + + expect(listElement.componentAtIndex).toBeNull(); + expect(listElement.enqueueComponent).toBeNull(); + expect(listElement.componentAtIndexes).toBeNull(); + }); +}); diff --git a/packages/react/runtime/__test__/utils/globals.js b/packages/react/runtime/__test__/utils/globals.js index feb13c4c8e..0ca272d334 100644 --- a/packages/react/runtime/__test__/utils/globals.js +++ b/packages/react/runtime/__test__/utils/globals.js @@ -13,6 +13,37 @@ const app = { }), }; +const native = { + _listeners: {}, + onTriggerEvent: undefined, + postMessage: vi.fn((_message) => {}), + addEventListener: vi.fn((type, listener) => { + if (!native._listeners[type]) { + native._listeners[type] = []; + } + native._listeners[type].push(listener); + }), + removeEventListener: vi.fn((type, listener) => { + if (native._listeners[type]) { + native._listeners[type] = native._listeners[type].filter(l => l !== listener); + } + }), + dispatchEvent: vi.fn((event) => { + if (native._listeners[event.type]) { + native._listeners[event.type].forEach(listener => listener(event)); + } + return { canceled: false }; + }), + _clear: () => { + native._listeners = {}; + native.onTriggerEvent = undefined; + native.postMessage.mockClear(); + native.addEventListener.mockClear(); + native.removeEventListener.mockClear(); + native.dispatchEvent.mockClear(); + }, +}; + const performance = { __functionCallHistory: [], _generatePipelineOptions: vi.fn(() => { @@ -126,6 +157,7 @@ function injectGlobals() { globalThis.lynx = { queueMicrotask: Promise.prototype.then.bind(Promise.resolve()), getNativeApp: () => app, + getNative: () => native, performance, createSelectorQuery: () => { return new SelectorQuery(); @@ -169,6 +201,7 @@ function injectGlobals() { beforeEach(() => { performance.profileStart.mockClear(); performance.profileEnd.mockClear(); + native._clear(); }); afterEach((context) => { diff --git a/packages/react/runtime/src/snapshot/list.ts b/packages/react/runtime/src/snapshot/list.ts index d7ff523947..35b5e65f25 100644 --- a/packages/react/runtime/src/snapshot/list.ts +++ b/packages/react/runtime/src/snapshot/list.ts @@ -22,6 +22,13 @@ export function snapshotCreateList( componentAtIndexes, ); const listID = __GetElementUniqueID(list); + + if (typeof lynx !== 'undefined' && typeof lynx.getNative === 'function') { + lynx.getNative()?.addEventListener('__DestroyLifetime', () => { + __UpdateListCallbacks(list, null, null, null); + }); + } + gSignMap[listID] = signMap; gRecycleMap[listID] = recycleMap; return list; diff --git a/packages/react/runtime/types/types.d.ts b/packages/react/runtime/types/types.d.ts index 7364370a38..42828b421f 100644 --- a/packages/react/runtime/types/types.d.ts +++ b/packages/react/runtime/types/types.d.ts @@ -98,9 +98,9 @@ declare global { ): void; declare function __UpdateListCallbacks( list: FiberElement, - componentAtIndex: ComponentAtIndexCallback, - enqueueComponent: EnqueueComponentCallback, - componentAtIndexes: ComponentAtIndexesCallback, + componentAtIndex: ComponentAtIndexCallback | null, + enqueueComponent: EnqueueComponentCallback | null, + componentAtIndexes: ComponentAtIndexesCallback | null, ): void; declare function __OnLifecycleEvent(...args: any[]): void; declare function _ReportError( diff --git a/packages/testing-library/testing-environment/src/index.ts b/packages/testing-library/testing-environment/src/index.ts index 601e7ea3ff..73b2f52bc1 100644 --- a/packages/testing-library/testing-environment/src/index.ts +++ b/packages/testing-library/testing-environment/src/index.ts @@ -249,8 +249,36 @@ function injectMainThreadGlobals(target?: any, polyfills?: any) { target.__TESTING_FORCE_RENDER_TO_OPCODE__ = false; target.__ENABLE_SSR__ = false; target.globDynamicComponentEntry = '__Card__'; + + const native = { + _listeners: {} as Record void)[]>, + onTriggerEvent: undefined as ((event: any) => void) | undefined, + postMessage: ((_message: any) => {}), + addEventListener: ((type: string, listener: (event: any) => void) => { + if (!native._listeners[type]) { + native._listeners[type] = []; + } + native._listeners[type].push(listener); + }), + removeEventListener: ((type: string, listener: (event: any) => void) => { + if (native._listeners[type]) { + native._listeners[type] = native._listeners[type].filter(l => + l !== listener + ); + } + }), + dispatchEvent: ((event: { type: string; data?: any }) => { + const listeners = native._listeners[event.type]; + if (listeners) { + listeners.forEach(listener => listener(event)); + } + return { canceled: false }; + }), + }; + target.lynx = { performance, + getNative: () => native, getCoreContext: (() => CoreContext), /*