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/list.ts b/packages/react/runtime/src/list.ts index 02463cbeb4..1c32d70591 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]; + + /* v8 ignore start */ if (!signMap || !recycleMap) { + // 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 ?? {}; 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]`); }); });