Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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/dark-candies-joke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@lynx-js/react": patch
---

fix: main thread events and gestures memory leak in PrimJS RC mode
54 changes: 54 additions & 0 deletions packages/react/runtime/__test__/lynx/mts-destroy.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -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);
});
});
2 changes: 2 additions & 0 deletions packages/react/runtime/src/lynx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -37,6 +38,7 @@ if (__MAIN_THREAD__) {
if (__DEV__) {
injectLepusMethods();
}
registerDestroyMts();
}

if (__DEV__) {
Expand Down
25 changes: 25 additions & 0 deletions packages/react/runtime/src/lynx/mts-destroy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// 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');
try {
const root = __root as SnapshotInstance;
root.childNodes.forEach(child => root.removeChild(child));
} finally {
lynx.performance.profileEnd();
lynx.getNative().removeEventListener('__DestroyLifetime', destroyMts);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
28 changes: 28 additions & 0 deletions packages/react/runtime/src/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,32 @@ export function traverseSnapshotInstance<I extends WithChildren>(
}
}

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);
Comment thread
Yradex marked this conversation as resolved.
}
});
}
Comment thread
Yradex marked this conversation as resolved.

export interface SerializedSnapshotInstance {
id: number;
type: string;
Expand Down Expand Up @@ -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
Expand All @@ -622,6 +649,7 @@ export class SnapshotInstance {
snapshotDestroyList(v);
}

clearSnapshotInstanceValuesWithUpdater(v);
v.__parent = null;
v.__previousSibling = null;
v.__nextSibling = null;
Expand Down
7 changes: 5 additions & 2 deletions packages/react/runtime/src/snapshot/platformInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string, unknown>;
const value = newValue as unknown as Record<string, unknown>;
for (const k in value) {
if (platformInfoVirtualAttributes.has(k)) {
continue;
Expand Down
2 changes: 1 addition & 1 deletion packages/react/runtime/src/snapshot/spread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ function updateSpread(
elementIndex: number,
): void {
oldValue ??= {};
let newValue: Record<string, unknown> = snapshot.__values![index] as Record<string, unknown>; // compiler guarantee this must be an object;
let newValue: Record<string, unknown> = (snapshot.__values![index] ?? {}) as Record<string, unknown>; // compiler guarantee this must be an object;

const list = snapshot.parentNode;
if (list?.__snapshot_def.isListHolder) {
Expand Down
11 changes: 10 additions & 1 deletion packages/react/runtime/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,17 @@ export function isEmptyObject(obj?: object): obj is Record<string, never> {
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);
}
Expand Down
Loading