From 36fdd4b00d403129ff3f831f8c000f067afe489a Mon Sep 17 00:00:00 2001
From: BitterGourd <91231822+gaoachao@users.noreply.github.com>
Date: Wed, 11 Feb 2026 20:10:20 +0800
Subject: [PATCH 1/3] fix: properly cleanup __DestroyLifetime listeners and
listCallbacks in snapshotDestroyList
---
.changeset/tender-otters-cheat.md | 5 ++
packages/react/runtime/__test__/list.test.jsx | 51 ++++++++++++++++---
packages/react/runtime/src/snapshot/list.ts | 20 +++++++-
.../src/__tests__/list.test.jsx | 4 --
4 files changed, 68 insertions(+), 12 deletions(-)
create mode 100644 .changeset/tender-otters-cheat.md
diff --git a/.changeset/tender-otters-cheat.md b/.changeset/tender-otters-cheat.md
new file mode 100644
index 0000000000..999c06171c
--- /dev/null
+++ b/.changeset/tender-otters-cheat.md
@@ -0,0 +1,5 @@
+---
+"@lynx-js/react": patch
+---
+
+fix: properly cleanup `__DestroyLifetime` listeners and listCallbacks in `snapshotDestroyList`.
diff --git a/packages/react/runtime/__test__/list.test.jsx b/packages/react/runtime/__test__/list.test.jsx
index b08ccbb2cc..3b6eba5114 100644
--- a/packages/react/runtime/__test__/list.test.jsx
+++ b/packages/react/runtime/__test__/list.test.jsx
@@ -443,12 +443,10 @@ describe(`list componentAtIndex`, () => {
__pendingListUpdates.flush();
a.removeChild(b);
- expect(() => {
- elementTree.triggerComponentAtIndex(listRef, 0);
- }).toThrowErrorMatchingInlineSnapshot(`[Error: componentAtIndex called on removed list]`);
- expect(() => {
- elementTree.triggerEnqueueComponent(listRef, 0);
- }).toThrowErrorMatchingInlineSnapshot(`[Error: enqueueComponent called on removed list]`);
+
+ expect(listRef.componentAtIndex()).toBe(-1);
+ expect(listRef.enqueueComponent()).toBeUndefined();
+ expect(listRef.componentAtIndexes()).toBeUndefined();
});
it('should reuse and hydrate', () => {
@@ -4409,4 +4407,45 @@ describe('clear __UpdateListCallbacks', () => {
expect(listElement.enqueueComponent).toBeNull();
expect(listElement.componentAtIndexes).toBeNull();
});
+
+ it('should remove __DestroyLifetime listener when list is destroyed via removeChild', () => {
+ const s0 = __SNAPSHOT__(
+
+ {HOLE}
+ ,
+ );
+ const s1 = __SNAPSHOT__(
+
+ test
+ {HOLE}
+ ,
+ );
+
+ const root = new SnapshotInstance(s0);
+ root.ensureElements();
+
+ const a = new SnapshotInstance(s1);
+ root.insertBefore(a);
+
+ 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();
+
+ root.removeChild(a);
+
+ expect(lynx.getNative().removeEventListener).toHaveBeenCalledWith(
+ '__DestroyLifetime',
+ expect.any(Function),
+ );
+
+ expect(listElement.componentAtIndex()).toBe(-1);
+ expect(listElement.enqueueComponent()).toBeUndefined();
+ expect(listElement.componentAtIndexes()).toBeUndefined();
+ });
});
diff --git a/packages/react/runtime/src/snapshot/list.ts b/packages/react/runtime/src/snapshot/list.ts
index 35b5e65f25..0c7de36886 100644
--- a/packages/react/runtime/src/snapshot/list.ts
+++ b/packages/react/runtime/src/snapshot/list.ts
@@ -6,6 +6,8 @@ import { hydrate } from '../hydrate.js';
import { componentAtIndexFactory, enqueueComponentFactory, gRecycleMap, gSignMap } from '../list.js';
import type { SnapshotInstance } from '../snapshot.js';
+const destroyLifetimeHandlerMap = new Map void>();
+
export function snapshotCreateList(
pageId: number,
_ctx: SnapshotInstance,
@@ -24,9 +26,12 @@ export function snapshotCreateList(
const listID = __GetElementUniqueID(list);
if (typeof lynx !== 'undefined' && typeof lynx.getNative === 'function') {
- lynx.getNative()?.addEventListener('__DestroyLifetime', () => {
+ const cb = () => {
__UpdateListCallbacks(list, null, null, null);
- });
+ destroyLifetimeHandlerMap.delete(listID);
+ };
+ lynx.getNative()?.addEventListener('__DestroyLifetime', cb);
+ destroyLifetimeHandlerMap.set(listID, cb);
}
gSignMap[listID] = signMap;
@@ -38,6 +43,17 @@ export function snapshotDestroyList(si: SnapshotInstance): void {
const [, elementIndex] = si.__snapshot_def.slot[0]!;
const list = si.__elements![elementIndex]!;
const listID = __GetElementUniqueID(list);
+
+ __UpdateListCallbacks(list, () => -1, () => {}, () => {});
+
+ if (typeof lynx !== 'undefined' && typeof lynx.getNative === 'function') {
+ const cb = destroyLifetimeHandlerMap.get(listID);
+ if (cb) {
+ lynx.getNative()?.removeEventListener('__DestroyLifetime', cb);
+ destroyLifetimeHandlerMap.delete(listID);
+ }
+ }
+
delete gSignMap[listID];
delete gRecycleMap[listID];
}
diff --git a/packages/react/testing-library/src/__tests__/list.test.jsx b/packages/react/testing-library/src/__tests__/list.test.jsx
index 759b1e7c66..1bdfef64f8 100644
--- a/packages/react/testing-library/src/__tests__/list.test.jsx
+++ b/packages/react/testing-library/src/__tests__/list.test.jsx
@@ -529,10 +529,6 @@ describe('list', () => {
act(() => {
setHide(true);
});
-
- expect(() => {
- elementTree.leaveListItem(list, uid0);
- }).toThrowErrorMatchingInlineSnapshot(`[Error: enqueueComponent called on removed list]`);
});
});
From 8b9468d420c65dced3b47957b60fbbda54734f2a Mon Sep 17 00:00:00 2001
From: BitterGourd <91231822+gaoachao@users.noreply.github.com>
Date: Thu, 12 Feb 2026 11:40:48 +0800
Subject: [PATCH 2/3] chore: add comments
---
packages/react/runtime/src/list.ts | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/packages/react/runtime/src/list.ts b/packages/react/runtime/src/list.ts
index 02463cbeb4..af9bd44b2e 100644
--- a/packages/react/runtime/src/list.ts
+++ b/packages/react/runtime/src/list.ts
@@ -52,9 +52,14 @@ export function componentAtIndexFactory(
) => {
const signMap = gSignMap[listID];
const recycleMap = gRecycleMap[listID];
+
if (!signMap || !recycleMap) {
+ /* v8 ignore start */
+ // Theoretically unreachable since snapshotDestroyList clears componentAtIndex with a noop function.
+ // Kept as a safeguard in case the callback is somehow invoked after list removal.
throw new Error('componentAtIndex called on removed list');
}
+ /* v8 ignore end */
const platformInfo = childCtx.__listItemPlatformInfo ?? {};
From 8543dfe720de5649cf5a8f922695ccc829e6b0b6 Mon Sep 17 00:00:00 2001
From: BitterGourd <91231822+gaoachao@users.noreply.github.com>
Date: Tue, 24 Feb 2026 17:43:11 +0800
Subject: [PATCH 3/3] fix: coverage
---
packages/react/runtime/src/list.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/react/runtime/src/list.ts b/packages/react/runtime/src/list.ts
index af9bd44b2e..1c32d70591 100644
--- a/packages/react/runtime/src/list.ts
+++ b/packages/react/runtime/src/list.ts
@@ -53,8 +53,8 @@ export function componentAtIndexFactory(
const signMap = gSignMap[listID];
const recycleMap = gRecycleMap[listID];
+ /* v8 ignore start */
if (!signMap || !recycleMap) {
- /* v8 ignore start */
// Theoretically unreachable since snapshotDestroyList clears componentAtIndex with a noop function.
// Kept as a safeguard in case the callback is somehow invoked after list removal.
throw new Error('componentAtIndex called on removed list');