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),
/*