Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tender-otters-cheat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@lynx-js/react": patch
---

fix: properly cleanup `__DestroyLifetime` listeners and listCallbacks in `snapshotDestroyList`.
51 changes: 45 additions & 6 deletions packages/react/runtime/__test__/list.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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__(
<view>
{HOLE}
</view>,
);
const s1 = __SNAPSHOT__(
<view>
<text>test</text>
<list>{HOLE}</list>
</view>,
);

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();
});
});
5 changes: 5 additions & 0 deletions packages/react/runtime/src/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? {};

Expand Down
20 changes: 18 additions & 2 deletions packages/react/runtime/src/snapshot/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, () => void>();

export function snapshotCreateList(
pageId: number,
_ctx: SnapshotInstance,
Expand All @@ -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;
Expand All @@ -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];
}
4 changes: 0 additions & 4 deletions packages/react/testing-library/src/__tests__/list.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -529,10 +529,6 @@ describe('list', () => {
act(() => {
setHide(true);
});

expect(() => {
elementTree.leaveListItem(list, uid0);
}).toThrowErrorMatchingInlineSnapshot(`[Error: enqueueComponent called on removed list]`);
});
});

Expand Down
Loading