From 410ba8bab33d2d934e5ca6a17bf500322323f12f Mon Sep 17 00:00:00 2001 From: yradex <11014207+Yradex@users.noreply.github.com> Date: Mon, 12 Jan 2026 20:40:34 +0800 Subject: [PATCH 1/2] fix(react): MTFs and gestures memory leak in PrimJS RC mode --- .changeset/dark-candies-joke.md | 5 ++ .../runtime/__test__/lynx/mts-destroy.test.ts | 54 ++++++++++++++++ .../snapshot/removeChildClearValues.test.ts | 61 +++++++++++++++++++ packages/react/runtime/src/lynx.ts | 2 + .../react/runtime/src/lynx/mts-destroy.ts | 22 +++++++ packages/react/runtime/src/snapshot.ts | 28 +++++++++ packages/react/runtime/src/snapshot/spread.ts | 2 +- packages/react/runtime/src/utils.ts | 11 +++- 8 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 .changeset/dark-candies-joke.md create mode 100644 packages/react/runtime/__test__/lynx/mts-destroy.test.ts create mode 100644 packages/react/runtime/__test__/snapshot/removeChildClearValues.test.ts create mode 100644 packages/react/runtime/src/lynx/mts-destroy.ts diff --git a/.changeset/dark-candies-joke.md b/.changeset/dark-candies-joke.md new file mode 100644 index 0000000000..e844378030 --- /dev/null +++ b/.changeset/dark-candies-joke.md @@ -0,0 +1,5 @@ +--- +"@lynx-js/react": patch +--- + +fix: main thread events and gestures memory leak in PrimJS RC mode diff --git a/packages/react/runtime/__test__/lynx/mts-destroy.test.ts b/packages/react/runtime/__test__/lynx/mts-destroy.test.ts new file mode 100644 index 0000000000..ebc2b34f14 --- /dev/null +++ b/packages/react/runtime/__test__/lynx/mts-destroy.test.ts @@ -0,0 +1,54 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { registerDestroyMts } from '../../src/lynx/mts-destroy.js'; +import { __root } from '../../src/root.js'; +import { SnapshotInstance } from '../../src/snapshot.js'; +import { globalEnvManager } from '../utils/envManager.js'; + +describe('mts destroy', () => { + let originalGetNative: unknown; + let originalSystemInfo: unknown; + + beforeEach(() => { + globalEnvManager.resetEnv(); + originalGetNative = (lynx as any).getNative; + originalSystemInfo = (globalThis as any).SystemInfo; + }); + + afterEach(() => { + (lynx as any).getNative = originalGetNative; + (globalThis as any).SystemInfo = originalSystemInfo; + vi.restoreAllMocks(); + }); + + it('does nothing when sdk version is not supported', () => { + (globalThis as any).SystemInfo = { lynxSdkVersion: '3.3' }; + const addEventListener = vi.fn(); + (lynx as any).getNative = () => ({ addEventListener }); + + registerDestroyMts(); + expect(addEventListener).not.toBeCalled(); + }); + + it('removes root children on __DestroyLifetime', () => { + (globalThis as any).SystemInfo = { lynxSdkVersion: '3.4' }; + const addEventListener = vi.fn(); + const removeEventListener = vi.fn(); + (lynx as any).getNative = () => ({ addEventListener, removeEventListener }); + + const root = __root as unknown as SnapshotInstance; + const child = new SnapshotInstance('wrapper'); + root.insertBefore(child); + expect(root.childNodes.length).toBe(1); + + registerDestroyMts(); + expect(addEventListener).toBeCalledTimes(1); + + const cb = addEventListener.mock.calls[0]![1] as () => void; + cb(); + expect(root.childNodes.length).toBe(0); + expect(lynx.performance.profileStart).toBeCalledWith('ReactLynx::destroyMts'); + expect(lynx.performance.profileEnd).toBeCalled(); + expect(removeEventListener).toBeCalledWith('__DestroyLifetime', cb); + }); +}); diff --git a/packages/react/runtime/__test__/snapshot/removeChildClearValues.test.ts b/packages/react/runtime/__test__/snapshot/removeChildClearValues.test.ts new file mode 100644 index 0000000000..1c5294e74e --- /dev/null +++ b/packages/react/runtime/__test__/snapshot/removeChildClearValues.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { DynamicPartType } from '../../src/snapshot/dynamicPartType.js'; +import { createSnapshot, SnapshotInstance } from '../../src/snapshot.js'; +import { globalEnvManager } from '../utils/envManager.js'; + +describe('removeChild value clearing', () => { + beforeEach(() => { + globalEnvManager.resetEnv(); + }); + + it('clears values with updater before instance deletion', () => { + const parentType = 'removeChildClearValues-parent'; + const childType = 'removeChildClearValues-child'; + + const updater0 = vi.fn((ctx: SnapshotInstance, index: number, _oldValue: unknown) => { + expect(ctx.__values?.[index]).toBeUndefined(); + }); + const updater1 = vi.fn(); + const updater2 = vi.fn((ctx: SnapshotInstance, index: number, _oldValue: unknown) => { + expect(ctx.__values?.[index]).toBeUndefined(); + }); + + createSnapshot( + parentType, + null, + [], + [[DynamicPartType.ListChildren, 0]], + undefined, + undefined, + null, + true, + ); + + createSnapshot( + childType, + null, + [updater0, updater1, updater2] as any, + [], + undefined, + undefined, + null, + true, + ); + + const parent = new SnapshotInstance(parentType); + const child = new SnapshotInstance(childType); + child.__values = [1, undefined, { __spread: {}, a: 1 }]; + + parent.insertBefore(child); + parent.removeChild(child); + + expect(updater0).toHaveBeenCalledTimes(1); + expect(updater0).toHaveBeenCalledWith(child, 0, 1); + expect(updater1).toHaveBeenCalledTimes(0); + expect(updater2).toHaveBeenCalledTimes(1); + expect(updater2).toHaveBeenCalledWith(child, 2, { __spread: {}, a: 1 }); + + expect(child.__values?.[2]).toEqual(undefined); + }); +}); diff --git a/packages/react/runtime/src/lynx.ts b/packages/react/runtime/src/lynx.ts index 9ae7a65905..780c54096e 100644 --- a/packages/react/runtime/src/lynx.ts +++ b/packages/react/runtime/src/lynx.ts @@ -15,6 +15,7 @@ import { injectUpdateMainThread } from './lifecycle/patch/updateMainThread.js'; import { injectCalledByNative } from './lynx/calledByNative.js'; import { setupLynxEnv } from './lynx/env.js'; import { injectLepusMethods } from './lynx/injectLepusMethods.js'; +import { registerDestroyMts } from './lynx/mts-destroy.js'; import { initTimingAPI } from './lynx/performance.js'; import { injectTt } from './lynx/tt.js'; import { lynxQueueMicrotask } from './utils.js'; @@ -37,6 +38,7 @@ if (__MAIN_THREAD__) { if (__DEV__) { injectLepusMethods(); } + registerDestroyMts(); } if (__DEV__) { diff --git a/packages/react/runtime/src/lynx/mts-destroy.ts b/packages/react/runtime/src/lynx/mts-destroy.ts new file mode 100644 index 0000000000..5aba78eeab --- /dev/null +++ b/packages/react/runtime/src/lynx/mts-destroy.ts @@ -0,0 +1,22 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { __root } from '../root.js'; +import type { SnapshotInstance } from '../snapshot.js'; +import { isSdkVersionGt } from '../utils.js'; + +export function registerDestroyMts(): void { + if (!isSdkVersionGt(3, 3)) { + return; + } + lynx.getNative().addEventListener('__DestroyLifetime', destroyMts); +} + +function destroyMts(): void { + lynx.performance.profileStart('ReactLynx::destroyMts'); + const root = __root as SnapshotInstance; + root.childNodes.forEach(child => root.removeChild(child)); + lynx.performance.profileEnd(); + lynx.getNative().removeEventListener('__DestroyLifetime', destroyMts); +} diff --git a/packages/react/runtime/src/snapshot.ts b/packages/react/runtime/src/snapshot.ts index 52092c16f4..14a1edc887 100644 --- a/packages/react/runtime/src/snapshot.ts +++ b/packages/react/runtime/src/snapshot.ts @@ -260,6 +260,32 @@ export function traverseSnapshotInstance( } } +function clearSnapshotInstanceValuesWithUpdater(v: SnapshotInstance): void { + const values = v.__values; + const update = v.__snapshot_def.update; + if (!values || !update) { + return; + } + + __pendingListUpdates.runWithoutUpdates(() => { + const len = values.length; + for (let i = 0; i < len; i++) { + const updater = update[i]!; + + const oldValue = values[i]; + const newValue: unknown = undefined; + + if (oldValue === undefined || oldValue === null) { + values[i] = undefined; + continue; + } + + values[i] = newValue; + updater(v, i, oldValue); + } + }); +} + export interface SerializedSnapshotInstance { id: number; type: string; @@ -603,6 +629,7 @@ export class SnapshotInstance { this.__removeChild(child); traverseSnapshotInstance(child, v => { + clearSnapshotInstanceValuesWithUpdater(v); snapshotInstanceManager.values.delete(v.__id); }); // mark this child as deleted @@ -622,6 +649,7 @@ export class SnapshotInstance { snapshotDestroyList(v); } + clearSnapshotInstanceValuesWithUpdater(v); v.__parent = null; v.__previousSibling = null; v.__nextSibling = null; diff --git a/packages/react/runtime/src/snapshot/spread.ts b/packages/react/runtime/src/snapshot/spread.ts index c7f12d5c9f..71dacef349 100644 --- a/packages/react/runtime/src/snapshot/spread.ts +++ b/packages/react/runtime/src/snapshot/spread.ts @@ -47,7 +47,7 @@ function updateSpread( elementIndex: number, ): void { oldValue ??= {}; - let newValue: Record = snapshot.__values![index] as Record; // compiler guarantee this must be an object; + let newValue: Record = (snapshot.__values![index] ?? {}) as Record; // compiler guarantee this must be an object; const list = snapshot.parentNode; if (list?.__snapshot_def.isListHolder) { diff --git a/packages/react/runtime/src/utils.ts b/packages/react/runtime/src/utils.ts index 275f8794f5..5cc1229166 100644 --- a/packages/react/runtime/src/utils.ts +++ b/packages/react/runtime/src/utils.ts @@ -39,8 +39,17 @@ export function isEmptyObject(obj?: object): obj is Record { return true; } +export function safeMtsSystemInfo(): typeof SystemInfo { + if (typeof SystemInfo !== 'undefined') { + return SystemInfo; + } + + const { SystemInfo: lynxSystemInfo } = lynx as unknown as { SystemInfo?: typeof SystemInfo }; + return lynxSystemInfo ?? {/* empty obj for unit tests */} as typeof SystemInfo; +} + export function isSdkVersionGt(major: number, minor: number): boolean { - const lynxSdkVersion: string = SystemInfo.lynxSdkVersion || '1.0'; + const lynxSdkVersion: string = safeMtsSystemInfo().lynxSdkVersion ?? '1.0'; const version = lynxSdkVersion.split('.'); return Number(version[0]) > major || (Number(version[0]) == major && Number(version[1]) > minor); } From 9235133191752dac83fe48310ddeec44a19a3b27 Mon Sep 17 00:00:00 2001 From: yradex <11014207+Yradex@users.noreply.github.com> Date: Mon, 26 Jan 2026 19:52:59 +0800 Subject: [PATCH 2/2] fix: Safely extract platform info objects and ensure destroy cleanup always executes. --- packages/react/runtime/src/lynx/mts-destroy.ts | 11 +++++++---- packages/react/runtime/src/snapshot/platformInfo.ts | 7 +++++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/react/runtime/src/lynx/mts-destroy.ts b/packages/react/runtime/src/lynx/mts-destroy.ts index 5aba78eeab..fff999f16e 100644 --- a/packages/react/runtime/src/lynx/mts-destroy.ts +++ b/packages/react/runtime/src/lynx/mts-destroy.ts @@ -15,8 +15,11 @@ export function registerDestroyMts(): void { function destroyMts(): void { lynx.performance.profileStart('ReactLynx::destroyMts'); - const root = __root as SnapshotInstance; - root.childNodes.forEach(child => root.removeChild(child)); - lynx.performance.profileEnd(); - lynx.getNative().removeEventListener('__DestroyLifetime', destroyMts); + try { + const root = __root as SnapshotInstance; + root.childNodes.forEach(child => root.removeChild(child)); + } finally { + lynx.performance.profileEnd(); + lynx.getNative().removeEventListener('__DestroyLifetime', destroyMts); + } } diff --git a/packages/react/runtime/src/snapshot/platformInfo.ts b/packages/react/runtime/src/snapshot/platformInfo.ts index c7ff692fbb..585b85f0f9 100644 --- a/packages/react/runtime/src/snapshot/platformInfo.ts +++ b/packages/react/runtime/src/snapshot/platformInfo.ts @@ -40,7 +40,10 @@ function updateListItemPlatformInfo( oldValue: any, elementIndex: number, ): void { - const newValue = ctx.__listItemPlatformInfo = ctx.__values![index] as PlatformInfo; + const rawValue = ctx.__values?.[index]; + const newValue = ctx.__listItemPlatformInfo = rawValue && typeof rawValue === 'object' + ? (rawValue as PlatformInfo) + : ({} as PlatformInfo); if (__pendingListUpdates.values) { const list = ctx.parentNode; @@ -57,7 +60,7 @@ function updateListItemPlatformInfo( // No adding / removing keys. if (ctx.__elements) { const e = ctx.__elements[elementIndex]!; - const value = ctx.__values![index] as Record; + const value = newValue as unknown as Record; for (const k in value) { if (platformInfoVirtualAttributes.has(k)) { continue;