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');