From 67a9afc3afeafa02d1a391c14b95524b99e144ea Mon Sep 17 00:00:00 2001
From: yradex <11014207+Yradex@users.noreply.github.com>
Date: Tue, 3 Feb 2026 15:36:35 +0800
Subject: [PATCH 1/6] fix: reduce redundant updates for handlers and gestures
- Implement copy-on-commit for worklets, gestures, and spread props to avoid background-side mutation.
- Prevent _execId churn for stable references, reducing redundant native patches.
- Fix gesture removal cleanup when removed from spread props.
- Add regression tests for execId churn and gesture cleanup.
- Add changeset for @lynx-js/react-runtime.
---
.changeset/fix-react-runtime-execid-churn.md | 9 +
.../gesture/prepareGestureForCommit.test.js | 32 +++
.../gesture/processGesture-remove.test.js | 56 +++++
.../snapshot/mtf-execid-churn.test.jsx | 201 ++++++++++++++++++
.../src/snapshot/gesture/processGesture.ts | 16 ++
.../gesture/processGestureBagkround.ts | 62 +++++-
.../snapshot/snapshot/backgroundSnapshot.ts | 87 +++++---
7 files changed, 422 insertions(+), 41 deletions(-)
create mode 100644 .changeset/fix-react-runtime-execid-churn.md
create mode 100644 packages/react/runtime/__test__/snapshot/gesture/prepareGestureForCommit.test.js
create mode 100644 packages/react/runtime/__test__/snapshot/gesture/processGesture-remove.test.js
create mode 100644 packages/react/runtime/__test__/snapshot/mtf-execid-churn.test.jsx
diff --git a/.changeset/fix-react-runtime-execid-churn.md b/.changeset/fix-react-runtime-execid-churn.md
new file mode 100644
index 0000000000..9cc4297d3b
--- /dev/null
+++ b/.changeset/fix-react-runtime-execid-churn.md
@@ -0,0 +1,9 @@
+---
+"@lynx-js/react": patch
+---
+
+fix: reduce redundant updates for main-thread handlers and gestures
+
+- Updates are faster when the main-thread event handler or gesture object is stable across rerenders (fewer unnecessary native updates).
+- Spread props rerenders that don't semantically change the handler/gesture no longer trigger redundant updates.
+- Removing a gesture from spread props reliably clears the gesture state on the target element.
diff --git a/packages/react/runtime/__test__/snapshot/gesture/prepareGestureForCommit.test.js b/packages/react/runtime/__test__/snapshot/gesture/prepareGestureForCommit.test.js
new file mode 100644
index 0000000000..7cb1863756
--- /dev/null
+++ b/packages/react/runtime/__test__/snapshot/gesture/prepareGestureForCommit.test.js
@@ -0,0 +1,32 @@
+import { describe, expect, it } from 'vitest';
+
+import { prepareGestureForCommit } from '../../../src/snapshot/gesture/processGestureBagkround.js';
+
+describe('prepareGestureForCommit', () => {
+ it('does not mutate input gesture and supports non-object callbacks', () => {
+ const gesture = {
+ id: 1,
+ type: 0,
+ callbacks: {
+ onUpdate: null,
+ },
+ __isGesture: true,
+ toJSON() {
+ const { toJSON, ...rest } = this;
+ return {
+ ...rest,
+ __isSerialized: true,
+ };
+ },
+ };
+
+ const committed = prepareGestureForCommit(gesture);
+ expect(committed).not.toBe(gesture);
+ expect(committed.callbacks).not.toBe(gesture.callbacks);
+ expect(committed.callbacks.onUpdate).toBe(null);
+
+ // Committed payload should serialize itself, not rely on the original object's toJSON.
+ const json = committed.toJSON();
+ expect(json.__isSerialized).toBe(true);
+ });
+});
diff --git a/packages/react/runtime/__test__/snapshot/gesture/processGesture-remove.test.js b/packages/react/runtime/__test__/snapshot/gesture/processGesture-remove.test.js
new file mode 100644
index 0000000000..98197fa40d
--- /dev/null
+++ b/packages/react/runtime/__test__/snapshot/gesture/processGesture-remove.test.js
@@ -0,0 +1,56 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { processGesture } from '../../../src/snapshot/gesture/processGesture.js';
+
+describe('processGesture', () => {
+ const originalSetAttribute = globalThis.__SetAttribute;
+
+ afterEach(() => {
+ globalThis.__SetAttribute = originalSetAttribute;
+ });
+
+ it('clears native gesture state when gesture is removed', () => {
+ const setAttribute = vi.fn();
+ globalThis.__SetAttribute = setAttribute;
+
+ const dom = {};
+ const oldGesture = {
+ type: 0,
+ __isSerialized: true,
+ };
+
+ processGesture(dom, undefined, oldGesture, false);
+
+ expect(setAttribute).toHaveBeenCalledWith(dom, 'has-react-gesture', null);
+ expect(setAttribute).toHaveBeenCalledWith(dom, 'flatten', null);
+ expect(setAttribute).toHaveBeenCalledWith(dom, 'gesture', null);
+ });
+
+ it('does not clear native state when domSet=true', () => {
+ const setAttribute = vi.fn();
+ globalThis.__SetAttribute = setAttribute;
+
+ const dom = {};
+ const oldGesture = {
+ type: 0,
+ __isSerialized: true,
+ };
+
+ processGesture(dom, undefined, oldGesture, false, { domSet: true });
+ expect(setAttribute).not.toHaveBeenCalled();
+ });
+
+ it('does not clear native state when oldGesture is not serialized', () => {
+ const setAttribute = vi.fn();
+ globalThis.__SetAttribute = setAttribute;
+
+ const dom = {};
+ const oldGesture = {
+ type: 0,
+ __isSerialized: false,
+ };
+
+ processGesture(dom, undefined, oldGesture, false);
+ expect(setAttribute).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/react/runtime/__test__/snapshot/mtf-execid-churn.test.jsx b/packages/react/runtime/__test__/snapshot/mtf-execid-churn.test.jsx
new file mode 100644
index 0000000000..2f2fb1256d
--- /dev/null
+++ b/packages/react/runtime/__test__/snapshot/mtf-execid-churn.test.jsx
@@ -0,0 +1,201 @@
+import { render } from 'preact';
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { useState } from '../../src/index';
+import { replaceCommitHook } from '../../src/lifecycle/patch/commit';
+import { injectUpdateMainThread } from '../../src/lifecycle/patch/updateMainThread';
+import { __root } from '../../src/root';
+import { setupPage } from '../../src/snapshot';
+import { globalEnvManager } from '../utils/envManager';
+import { elementTree, waitSchedule } from '../utils/nativeMethod';
+
+function getSnapshotPatchFromPatchUpdateCall(call) {
+ const obj = call[1];
+ const parsed = JSON.parse(obj.data);
+ return parsed.patchList?.[0]?.snapshotPatch;
+}
+
+beforeAll(() => {
+ setupPage(__CreatePage('0', 0));
+ injectUpdateMainThread();
+ replaceCommitHook();
+});
+
+beforeEach(() => {
+ globalEnvManager.resetEnv();
+ SystemInfo.lynxSdkVersion = '999.999';
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ elementTree.clear();
+});
+
+describe('Patch size / execId churn', () => {
+ it('MTF: stable ctx reference should not generate snapshotPatch', async function() {
+ const mtf = {
+ _wkltId: '835d:450ef:stable',
+ };
+
+ let bump_;
+ function Comp() {
+ const [, setTick] = useState(0);
+ bump_ = () => {
+ setTick(v => v + 1);
+ };
+ return (
+
+ 1
+
+ );
+ }
+
+ // main thread render
+ {
+ __root.__jsx = ;
+ renderPage();
+ }
+
+ // background render
+ {
+ globalEnvManager.switchToBackground();
+ render(, __root);
+ }
+
+ // hydrate
+ {
+ lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]);
+
+ globalEnvManager.switchToMainThread();
+ const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0];
+ globalThis[rLynxChange[0]](rLynxChange[1]);
+ }
+
+ // rerender with no semantic changes
+ {
+ globalEnvManager.switchToBackground();
+ lynx.getNativeApp().callLepusMethod.mockClear();
+ bump_();
+ await waitSchedule();
+
+ globalEnvManager.switchToMainThread();
+ const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0];
+ expect(getSnapshotPatchFromPatchUpdateCall(rLynxChange)).toBeUndefined();
+ }
+ });
+
+ it('spread: stable semantics should not generate snapshotPatch', async function() {
+ let bump_;
+ function Comp() {
+ const [, setTick] = useState(0);
+ bump_ = () => {
+ setTick(v => v + 1);
+ };
+ // Simulate typical compiled output: a fresh ctx object each render.
+ // `_wkltId` stays the same, but runtime injects `_execId`, causing patch churn.
+ const spread = {
+ 'main-thread:bindtap': {
+ _wkltId: '835d:450ef:stable',
+ },
+ };
+ return (
+
+ 1
+
+ );
+ }
+
+ // main thread render
+ {
+ __root.__jsx = ;
+ renderPage();
+ }
+
+ // background render
+ {
+ globalEnvManager.switchToBackground();
+ render(, __root);
+ }
+
+ // hydrate
+ {
+ lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]);
+
+ globalEnvManager.switchToMainThread();
+ const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0];
+ globalThis[rLynxChange[0]](rLynxChange[1]);
+ }
+
+ // rerender with no semantic changes
+ {
+ globalEnvManager.switchToBackground();
+ lynx.getNativeApp().callLepusMethod.mockClear();
+ bump_();
+ await waitSchedule();
+
+ globalEnvManager.switchToMainThread();
+ const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0];
+ expect(getSnapshotPatchFromPatchUpdateCall(rLynxChange)).toBeUndefined();
+ }
+ });
+
+ it('gesture: stable gesture reference should not generate snapshotPatch', async function() {
+ const stableGesture = {
+ id: 1,
+ type: 0,
+ callbacks: {
+ onUpdate: {
+ _wkltId: 'bdd4:dd564:stable',
+ },
+ },
+ __isGesture: true,
+ toJSON() {
+ const { toJSON, ...rest } = this;
+ return {
+ ...rest,
+ __isSerialized: true,
+ };
+ },
+ };
+
+ function Comp(_props) {
+ return (
+
+ 1
+
+ );
+ }
+
+ // main thread render
+ {
+ __root.__jsx = ;
+ renderPage();
+ }
+
+ // background render
+ {
+ globalEnvManager.switchToBackground();
+ render(, __root);
+ }
+
+ // hydrate
+ {
+ lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]);
+
+ globalEnvManager.switchToMainThread();
+ const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0];
+ globalThis[rLynxChange[0]](rLynxChange[1]);
+ }
+
+ // rerender with no semantic changes
+ {
+ globalEnvManager.switchToBackground();
+ lynx.getNativeApp().callLepusMethod.mockClear();
+ render(, __root);
+
+ globalEnvManager.switchToMainThread();
+ const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0];
+ expect(getSnapshotPatchFromPatchUpdateCall(rLynxChange)).toBeUndefined();
+ }
+ });
+});
diff --git a/packages/react/runtime/src/snapshot/gesture/processGesture.ts b/packages/react/runtime/src/snapshot/gesture/processGesture.ts
index 6273f9584a..1169fb806d 100644
--- a/packages/react/runtime/src/snapshot/gesture/processGesture.ts
+++ b/packages/react/runtime/src/snapshot/gesture/processGesture.ts
@@ -161,6 +161,22 @@ export function processGesture(
},
): void {
const domSet = gestureOptions?.domSet === true;
+ if (!gesture || !isSerializedGesture(gesture)) {
+ const { oldBaseGesturesById } = collectOldGestureInfo(oldGesture);
+ for (const oldBaseGesture of oldBaseGesturesById.values()) {
+ removeGestureDetector(dom, oldBaseGesture.id);
+ }
+
+ // Clearing the attrs keeps the legacy main-thread state in sync when
+ // gesture props disappear during spread/key-removal updates.
+ if (!domSet && oldBaseGesturesById.size > 0) {
+ __SetAttribute(dom, 'has-react-gesture', null);
+ __SetAttribute(dom, 'flatten', null);
+ __SetAttribute(dom, 'gesture', null);
+ }
+ return;
+ }
+
const { uniqOldBaseGestures, oldBaseGesturesById } = collectOldGestureInfo(oldGesture);
// Fast path for the most common case: single base gesture update.
diff --git a/packages/react/runtime/src/snapshot/gesture/processGestureBagkround.ts b/packages/react/runtime/src/snapshot/gesture/processGestureBagkround.ts
index 39badeeb44..242b523ab9 100644
--- a/packages/react/runtime/src/snapshot/gesture/processGestureBagkround.ts
+++ b/packages/react/runtime/src/snapshot/gesture/processGestureBagkround.ts
@@ -1,19 +1,65 @@
// Copyright 2025 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 type { Worklet } from '@lynx-js/react/worklet-runtime/bindings';
+
import { GestureTypeInner } from './types.js';
import type { BaseGesture, ComposedGesture, GestureKind } from './types.js';
import { onPostWorkletCtx } from '../worklet/ctx.js';
-export function processGestureBackground(gesture: GestureKind): void {
+function prepareWorkletForCommit(value: Worklet): Worklet | null {
+ // Copy-on-commit: keep the background-side gesture/worklet objects clean.
+ // `_execId` is injected into the payload object that will be sent to the main thread.
+ const copy = { ...(value as unknown as Record) } as unknown as Worklet;
+ return onPostWorkletCtx(copy);
+}
+
+function gestureToJSON(this: Record): Record {
+ // Ensure serialization uses the committed object itself instead of any
+ // user-provided `toJSON` implementation that may close over the original object.
+ const { toJSON: _ignoredToJSON, ...rest } = this;
+ return {
+ ...rest,
+ __isSerialized: true,
+ };
+}
+
+/**
+ * Prepare a gesture payload to be sent to the main thread.
+ *
+ * This function returns a copy of the input object and injects `_execId` into
+ * its worklet callbacks. The background-side gesture object MUST NOT be mutated,
+ * otherwise `_execId` churn would pollute the cached values and cause redundant patches.
+ */
+export function prepareGestureForCommit(gesture: GestureKind): GestureKind {
if (gesture.type === GestureTypeInner.COMPOSED) {
- for (const subGesture of (gesture as ComposedGesture).gestures) {
- processGestureBackground(subGesture);
- }
- } else {
- const baseGesture = gesture as BaseGesture;
- for (const [name, value] of Object.entries(baseGesture.callbacks)) {
- baseGesture.callbacks[name] = onPostWorkletCtx(value)!;
+ const composed = gesture as ComposedGesture;
+ const committed: ComposedGesture & { toJSON: typeof gestureToJSON } = {
+ ...composed,
+ gestures: composed.gestures.map((g) => prepareGestureForCommit(g)),
+ toJSON: gestureToJSON,
+ };
+ return committed;
+ }
+
+ const baseGesture = gesture as BaseGesture;
+ const committedCallbacks: BaseGesture['callbacks'] = { ...baseGesture.callbacks };
+ for (const name of Object.keys(committedCallbacks)) {
+ const callback = committedCallbacks[name];
+ if (callback == null) {
+ // Some gesture implementations may intentionally leave callbacks unset.
+ // Treat null/undefined as "no handler" and keep it untouched.
+ continue;
}
+ // `onPostWorkletCtx` may report errors and return null depending on runtime configuration.
+ // Keep behavior consistent with the previous implementation (which used `!`).
+ committedCallbacks[name] = prepareWorkletForCommit(callback)!;
}
+
+ const committed: BaseGesture & { toJSON: typeof gestureToJSON } = {
+ ...baseGesture,
+ callbacks: committedCallbacks,
+ toJSON: gestureToJSON,
+ };
+ return committed;
}
diff --git a/packages/react/runtime/src/snapshot/snapshot/backgroundSnapshot.ts b/packages/react/runtime/src/snapshot/snapshot/backgroundSnapshot.ts
index 4d777daf4f..7bfdb5ebe3 100644
--- a/packages/react/runtime/src/snapshot/snapshot/backgroundSnapshot.ts
+++ b/packages/react/runtime/src/snapshot/snapshot/backgroundSnapshot.ts
@@ -23,7 +23,7 @@ import { traverseSnapshotInstance } from './utils.js';
import { isDirectOrDeepEqual } from '../../utils.js';
import { profileEnd, profileStart } from '../debug/profile.js';
import { clearSnapshotVNodeSource, getSnapshotVNodeSource, moveSnapshotVNodeSource } from '../debug/vnodeSource.js';
-import { processGestureBackground } from '../gesture/processGestureBagkround.js';
+import { prepareGestureForCommit } from '../gesture/processGestureBagkround.js';
import type { GestureKind } from '../gesture/types.js';
import { globalBackgroundSnapshotInstancesToRemove } from '../lifecycle/patch/globalState.js';
import {
@@ -104,6 +104,34 @@ export const backgroundSnapshotInstanceManager: {
},
};
+function prepareWorkletForCommit(worklet: Worklet): Worklet | null {
+ // Copy-on-commit: do not mutate the background-side worklet ctx.
+ // `_execId` is injected into the payload object that will be sent to the main thread.
+ return onPostWorkletCtx({ ...(worklet as unknown as Record) } as Worklet);
+}
+
+function prepareSpreadForCommit(
+ spread: Record,
+ oldSpread: Record | undefined,
+): Record {
+ const committed: Record = { ...spread };
+ for (const key in committed) {
+ const v = committed[key];
+ if (key === '__lynx_timing_flag' && oldSpread?.[key] != v && globalPipelineOptions) {
+ globalPipelineOptions.needTimestamps = true;
+ }
+ if (!v || typeof v !== 'object') {
+ continue;
+ }
+ if ('_wkltId' in (v as Record)) {
+ committed[key] = prepareWorkletForCommit(v as Worklet);
+ } else if ('__isGesture' in (v as Record)) {
+ committed[key] = prepareGestureForCommit(v as GestureKind);
+ }
+ }
+ return committed;
+}
+
export class BackgroundSnapshotInstance {
constructor(public type: string) {
// Suspense uses 'div'
@@ -389,33 +417,34 @@ export class BackgroundSnapshotInstance {
this.__id,
index,
);
- if (needUpdate) {
- for (const key in newSpread) {
- const newSpreadValue = newSpread[key];
- if (!newSpreadValue) {
- continue;
- }
- if ((newSpreadValue as { _wkltId?: string })._wkltId) {
- newSpread[key] = onPostWorkletCtx(newSpreadValue as Worklet);
- } else if ((newSpreadValue as { __isGesture?: boolean }).__isGesture) {
- processGestureBackground(newSpreadValue as GestureKind);
- } else if (key == '__lynx_timing_flag' && oldSpread?.[key] != newSpreadValue && globalPipelineOptions) {
- globalPipelineOptions.needTimestamps = true;
- }
- }
- }
- return { needUpdate, valueToCommit: newSpread };
+ return {
+ needUpdate,
+ valueToCommit: needUpdate ? prepareSpreadForCommit(newSpread, oldSpread) : newSpread,
+ };
}
if ('__ref' in newValueObj) {
queueRefAttrUpdate(oldValue as Ref, newValueObj as Ref, this.__id, index);
return { needUpdate: false, valueToCommit: 1 };
}
if ('_wkltId' in newValueObj) {
- return { needUpdate: true, valueToCommit: onPostWorkletCtx(newValueObj as Worklet) };
+ // Worklet ctx can be stable across rerenders (e.g. memoized by the user).
+ // In that case we should NOT re-register / re-send it, otherwise `_execId` churn
+ // will cause unnecessary patches.
+ const needUpdate = oldValue !== newValue;
+ return {
+ needUpdate,
+ valueToCommit: needUpdate ? prepareWorkletForCommit(newValueObj as Worklet) : newValue,
+ };
}
if ('__isGesture' in newValueObj) {
- processGestureBackground(newValueObj as unknown as GestureKind);
- return { needUpdate: true, valueToCommit: newValue };
+ // Gestures are large objects; if the reference is stable, avoid reprocessing and patching.
+ const needUpdate = oldValue !== newValue;
+ return {
+ needUpdate,
+ valueToCommit: needUpdate
+ ? prepareGestureForCommit(newValueObj as unknown as GestureKind)
+ : newValue,
+ };
}
if ('__ltf' in newValueObj) {
// __lynx_timing_flag
@@ -466,25 +495,17 @@ export function hydrate(
// `value.__spread` my contain event ids using snapshot ids before hydration. Remove it.
delete value.__spread;
const __spread = transformSpread(after, index, value);
- for (const key in __spread) {
- const v = __spread[key];
- if (v && typeof v === 'object') {
- if ('_wkltId' in v) {
- onPostWorkletCtx(v as Worklet);
- } else if ('__isGesture' in v) {
- processGestureBackground(v as GestureKind);
- }
- }
- }
+ // Cache a clean spread for future diffs. For the patch payload, create a committed copy
+ // with runtime fields (e.g. `_execId`) injected.
(after.__values![index]! as Record)['__spread'] = __spread;
- value = __spread;
+ value = prepareSpreadForCommit(__spread, old as Record | undefined);
} else if ('__ref' in value) {
// skip patch
value = old;
} else if ('_wkltId' in value) {
- onPostWorkletCtx(value as Worklet);
+ value = prepareWorkletForCommit(value as Worklet);
} else if ('__isGesture' in value) {
- processGestureBackground(value as GestureKind);
+ value = prepareGestureForCommit(value as GestureKind);
}
} else if (typeof value === 'function') {
if ('__ref' in value) {
From 4e0ce7085c674273e724cfe59a2138b0c3d67e74 Mon Sep 17 00:00:00 2001
From: yradex <11014207+Yradex@users.noreply.github.com>
Date: Tue, 3 Feb 2026 16:06:03 +0800
Subject: [PATCH 2/6] test: improve `mtf-execid-churn` snapshot test
reliability by adding assertions and awaiting schedule.
---
.../react/runtime/__test__/snapshot/mtf-execid-churn.test.jsx | 3 +++
1 file changed, 3 insertions(+)
diff --git a/packages/react/runtime/__test__/snapshot/mtf-execid-churn.test.jsx b/packages/react/runtime/__test__/snapshot/mtf-execid-churn.test.jsx
index 2f2fb1256d..2109a5333e 100644
--- a/packages/react/runtime/__test__/snapshot/mtf-execid-churn.test.jsx
+++ b/packages/react/runtime/__test__/snapshot/mtf-execid-churn.test.jsx
@@ -10,7 +10,9 @@ import { globalEnvManager } from '../utils/envManager';
import { elementTree, waitSchedule } from '../utils/nativeMethod';
function getSnapshotPatchFromPatchUpdateCall(call) {
+ expect(call, 'expected a patch update call').toBeTruthy();
const obj = call[1];
+ expect(obj?.data, 'expected patch payload').toBeTypeOf('string');
const parsed = JSON.parse(obj.data);
return parsed.patchList?.[0]?.snapshotPatch;
}
@@ -192,6 +194,7 @@ describe('Patch size / execId churn', () => {
globalEnvManager.switchToBackground();
lynx.getNativeApp().callLepusMethod.mockClear();
render(, __root);
+ await waitSchedule();
globalEnvManager.switchToMainThread();
const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0];
From bff7a98c2ce48cea4a2e36cce76ec567bfaf047d Mon Sep 17 00:00:00 2001
From: yradex <11014207+Yradex@users.noreply.github.com>
Date: Fri, 17 Apr 2026 15:19:26 +0800
Subject: [PATCH 3/6] fix: reuse gesture runtime toJSON during commit
---
.../lynx/gesture-runtime/src/baseGesture.ts | 2 +-
.../lynx/gesture-runtime/src/composition.ts | 2 +-
.../__test__/snapshot/gesture.test.jsx | 85 ++++++++++---------
.../gesture/prepareGestureForCommit.test.js | 4 +-
.../gesture/processGesture-remove.test.js | 56 ------------
.../snapshot/mtf-execid-churn.test.jsx | 16 +++-
.../gesture/processGestureBagkround.ts | 16 +---
7 files changed, 67 insertions(+), 114 deletions(-)
delete mode 100644 packages/react/runtime/__test__/snapshot/gesture/processGesture-remove.test.js
diff --git a/packages/lynx/gesture-runtime/src/baseGesture.ts b/packages/lynx/gesture-runtime/src/baseGesture.ts
index 4ec2b5aa4d..df4a825ecf 100644
--- a/packages/lynx/gesture-runtime/src/baseGesture.ts
+++ b/packages/lynx/gesture-runtime/src/baseGesture.ts
@@ -255,7 +255,7 @@ abstract class BaseGesture<
return removeUndefined(result);
};
- toJSON = (): Record => {
+ toJSON = function(this: BaseGesture): Record {
return this.serialize();
};
diff --git a/packages/lynx/gesture-runtime/src/composition.ts b/packages/lynx/gesture-runtime/src/composition.ts
index 291c10a383..2b71a30aba 100644
--- a/packages/lynx/gesture-runtime/src/composition.ts
+++ b/packages/lynx/gesture-runtime/src/composition.ts
@@ -107,7 +107,7 @@ class ComposedGesture implements GestureKind {
};
};
- toJSON = (): Record => {
+ toJSON = function(this: ComposedGesture): Record {
return this.serialize();
};
}
diff --git a/packages/react/runtime/__test__/snapshot/gesture.test.jsx b/packages/react/runtime/__test__/snapshot/gesture.test.jsx
index 6dda59d405..f432824d4f 100644
--- a/packages/react/runtime/__test__/snapshot/gesture.test.jsx
+++ b/packages/react/runtime/__test__/snapshot/gesture.test.jsx
@@ -68,10 +68,12 @@ describe('Gesture', () => {
},
},
__isGesture: true,
- toJSON: () => ({
- ...gesture,
- __isSerialized: true,
- }),
+ toJSON: function() {
+ return {
+ ...this,
+ __isSerialized: true,
+ };
+ },
};
return (
@@ -171,10 +173,12 @@ describe('Gesture', () => {
simultaneousWith: [{ id: 2 }],
continueWith: [{ id: 2 }],
__isGesture: true,
- toJSON: () => ({
- ...panGesture,
- __isSerialized: true,
- }),
+ toJSON: function() {
+ return {
+ ...this,
+ __isSerialized: true,
+ };
+ },
};
const tapGesture = {
id: 2,
@@ -186,20 +190,24 @@ describe('Gesture', () => {
},
__isGesture: true,
waitFor: [{ id: 1 }],
- toJSON: () => ({
- ...tapGesture,
- __isSerialized: true,
- }),
+ toJSON: function() {
+ return {
+ ...this,
+ __isSerialized: true,
+ };
+ },
};
const gesture = {
type: -1,
__isGesture: true,
gestures: [panGesture, tapGesture],
- toJSON: () => ({
- ...gesture,
- __isSerialized: true,
- }),
+ toJSON: function() {
+ return {
+ ...this,
+ __isSerialized: true,
+ };
+ },
};
return (
@@ -302,10 +310,9 @@ describe('Gesture', () => {
},
},
__isGesture: true,
- toJSON() {
- const { toJSON, ...rest } = this;
+ toJSON: function() {
return {
- ...rest,
+ ...this,
__isSerialized: true,
};
},
@@ -443,10 +450,9 @@ describe('Gesture', () => {
},
},
__isGesture: true,
- toJSON() {
- const { toJSON, ...rest } = this;
+ toJSON: function() {
return {
- ...rest,
+ ...this,
__isSerialized: true,
};
},
@@ -568,10 +574,12 @@ describe('Gesture', () => {
minDistance: 100,
},
__isGesture: true,
- toJSON: () => ({
- ...gesture,
- __isSerialized: true,
- }),
+ toJSON: function() {
+ return {
+ ...this,
+ __isSerialized: true,
+ };
+ },
};
return (
@@ -676,10 +684,12 @@ describe('Gesture in spread', () => {
},
},
__isGesture: true,
- toJSON: () => ({
- ...gesture,
- __isSerialized: true,
- }),
+ toJSON: function() {
+ return {
+ ...this,
+ __isSerialized: true,
+ };
+ },
};
const props = {
@@ -806,10 +816,9 @@ describe('Gesture in spread', () => {
},
},
__isGesture: true,
- toJSON() {
- const { toJSON, ...rest } = this;
+ toJSON: function() {
return {
- ...rest,
+ ...this,
__isSerialized: true,
};
},
@@ -915,10 +924,9 @@ describe('Gesture in spread', () => {
},
},
__isGesture: true,
- toJSON() {
- const { toJSON, ...rest } = this;
+ toJSON: function() {
return {
- ...rest,
+ ...this,
__isSerialized: true,
};
},
@@ -1041,10 +1049,9 @@ describe('Gesture in spread', () => {
},
},
__isGesture: true,
- toJSON() {
- const { toJSON, ...rest } = this;
+ toJSON: function() {
return {
- ...rest,
+ ...this,
__isSerialized: true,
};
},
diff --git a/packages/react/runtime/__test__/snapshot/gesture/prepareGestureForCommit.test.js b/packages/react/runtime/__test__/snapshot/gesture/prepareGestureForCommit.test.js
index 7cb1863756..5566bc5e84 100644
--- a/packages/react/runtime/__test__/snapshot/gesture/prepareGestureForCommit.test.js
+++ b/packages/react/runtime/__test__/snapshot/gesture/prepareGestureForCommit.test.js
@@ -25,7 +25,9 @@ describe('prepareGestureForCommit', () => {
expect(committed.callbacks).not.toBe(gesture.callbacks);
expect(committed.callbacks.onUpdate).toBe(null);
- // Committed payload should serialize itself, not rely on the original object's toJSON.
+ expect(committed.toJSON).toBe(gesture.toJSON);
+
+ // Gesture runtime provides toJSON; ensure the committed object still serializes.
const json = committed.toJSON();
expect(json.__isSerialized).toBe(true);
});
diff --git a/packages/react/runtime/__test__/snapshot/gesture/processGesture-remove.test.js b/packages/react/runtime/__test__/snapshot/gesture/processGesture-remove.test.js
deleted file mode 100644
index 98197fa40d..0000000000
--- a/packages/react/runtime/__test__/snapshot/gesture/processGesture-remove.test.js
+++ /dev/null
@@ -1,56 +0,0 @@
-import { afterEach, describe, expect, it, vi } from 'vitest';
-
-import { processGesture } from '../../../src/snapshot/gesture/processGesture.js';
-
-describe('processGesture', () => {
- const originalSetAttribute = globalThis.__SetAttribute;
-
- afterEach(() => {
- globalThis.__SetAttribute = originalSetAttribute;
- });
-
- it('clears native gesture state when gesture is removed', () => {
- const setAttribute = vi.fn();
- globalThis.__SetAttribute = setAttribute;
-
- const dom = {};
- const oldGesture = {
- type: 0,
- __isSerialized: true,
- };
-
- processGesture(dom, undefined, oldGesture, false);
-
- expect(setAttribute).toHaveBeenCalledWith(dom, 'has-react-gesture', null);
- expect(setAttribute).toHaveBeenCalledWith(dom, 'flatten', null);
- expect(setAttribute).toHaveBeenCalledWith(dom, 'gesture', null);
- });
-
- it('does not clear native state when domSet=true', () => {
- const setAttribute = vi.fn();
- globalThis.__SetAttribute = setAttribute;
-
- const dom = {};
- const oldGesture = {
- type: 0,
- __isSerialized: true,
- };
-
- processGesture(dom, undefined, oldGesture, false, { domSet: true });
- expect(setAttribute).not.toHaveBeenCalled();
- });
-
- it('does not clear native state when oldGesture is not serialized', () => {
- const setAttribute = vi.fn();
- globalThis.__SetAttribute = setAttribute;
-
- const dom = {};
- const oldGesture = {
- type: 0,
- __isSerialized: false,
- };
-
- processGesture(dom, undefined, oldGesture, false);
- expect(setAttribute).not.toHaveBeenCalled();
- });
-});
diff --git a/packages/react/runtime/__test__/snapshot/mtf-execid-churn.test.jsx b/packages/react/runtime/__test__/snapshot/mtf-execid-churn.test.jsx
index 2109a5333e..299a1ef6fb 100644
--- a/packages/react/runtime/__test__/snapshot/mtf-execid-churn.test.jsx
+++ b/packages/react/runtime/__test__/snapshot/mtf-execid-churn.test.jsx
@@ -1,5 +1,5 @@
-import { render } from 'preact';
-import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { options, render } from 'preact';
+import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { useState } from '../../src/index';
import { replaceCommitHook } from '../../src/lifecycle/patch/commit';
@@ -9,6 +9,9 @@ import { setupPage } from '../../src/snapshot';
import { globalEnvManager } from '../utils/envManager';
import { elementTree, waitSchedule } from '../utils/nativeMethod';
+let prevLynxSdkVersion;
+let prevCommit;
+
function getSnapshotPatchFromPatchUpdateCall(call) {
expect(call, 'expected a patch update call').toBeTruthy();
const obj = call[1];
@@ -18,17 +21,26 @@ function getSnapshotPatchFromPatchUpdateCall(call) {
}
beforeAll(() => {
+ prevCommit = options.commit;
setupPage(__CreatePage('0', 0));
injectUpdateMainThread();
replaceCommitHook();
});
+afterAll(() => {
+ // Prevent leaking global state to other test files.
+ options.commit = prevCommit;
+ delete globalThis.rLynxChange;
+});
+
beforeEach(() => {
globalEnvManager.resetEnv();
+ prevLynxSdkVersion = SystemInfo.lynxSdkVersion;
SystemInfo.lynxSdkVersion = '999.999';
});
afterEach(() => {
+ SystemInfo.lynxSdkVersion = prevLynxSdkVersion;
vi.restoreAllMocks();
elementTree.clear();
});
diff --git a/packages/react/runtime/src/snapshot/gesture/processGestureBagkround.ts b/packages/react/runtime/src/snapshot/gesture/processGestureBagkround.ts
index 242b523ab9..72b3c4ccf3 100644
--- a/packages/react/runtime/src/snapshot/gesture/processGestureBagkround.ts
+++ b/packages/react/runtime/src/snapshot/gesture/processGestureBagkround.ts
@@ -14,16 +14,6 @@ function prepareWorkletForCommit(value: Worklet): Worklet | null {
return onPostWorkletCtx(copy);
}
-function gestureToJSON(this: Record): Record {
- // Ensure serialization uses the committed object itself instead of any
- // user-provided `toJSON` implementation that may close over the original object.
- const { toJSON: _ignoredToJSON, ...rest } = this;
- return {
- ...rest,
- __isSerialized: true,
- };
-}
-
/**
* Prepare a gesture payload to be sent to the main thread.
*
@@ -34,10 +24,9 @@ function gestureToJSON(this: Record): Record {
export function prepareGestureForCommit(gesture: GestureKind): GestureKind {
if (gesture.type === GestureTypeInner.COMPOSED) {
const composed = gesture as ComposedGesture;
- const committed: ComposedGesture & { toJSON: typeof gestureToJSON } = {
+ const committed: ComposedGesture = {
...composed,
gestures: composed.gestures.map((g) => prepareGestureForCommit(g)),
- toJSON: gestureToJSON,
};
return committed;
}
@@ -56,10 +45,9 @@ export function prepareGestureForCommit(gesture: GestureKind): GestureKind {
committedCallbacks[name] = prepareWorkletForCommit(callback)!;
}
- const committed: BaseGesture & { toJSON: typeof gestureToJSON } = {
+ const committed: BaseGesture = {
...baseGesture,
callbacks: committedCallbacks,
- toJSON: gestureToJSON,
};
return committed;
}
From cb1e3694b2c958e05d3c1208fe8ccd03f1f3ee5c Mon Sep 17 00:00:00 2001
From: yradex <11014207+Yradex@users.noreply.github.com>
Date: Mon, 20 Apr 2026 15:38:51 +0800
Subject: [PATCH 4/6] fix: address gesture review regressions
---
.../__test__/snapshot/gesture.test.jsx | 75 +++++++++++++++++++
.../gesture/prepareGestureForCommit.test.js | 58 +++++++++++++-
.../snapshot/gesture/processGesture.test.ts | 31 ++++++++
.../snapshot/mtf-execid-churn.test.jsx | 8 +-
.../src/snapshot/gesture/processGesture.ts | 15 ++--
.../gesture/processGestureBagkround.ts | 45 ++++++++++-
.../snapshot/snapshot/backgroundSnapshot.ts | 23 ++++--
7 files changed, 233 insertions(+), 22 deletions(-)
diff --git a/packages/react/runtime/__test__/snapshot/gesture.test.jsx b/packages/react/runtime/__test__/snapshot/gesture.test.jsx
index f432824d4f..af73da4409 100644
--- a/packages/react/runtime/__test__/snapshot/gesture.test.jsx
+++ b/packages/react/runtime/__test__/snapshot/gesture.test.jsx
@@ -1124,6 +1124,81 @@ describe('Gesture in spread', () => {
expect(elementTree.__GetGestureDetectorIds(textElement).includes(1)).toBe(false);
}
});
+ it('keeps flatten when spread removes gesture but retains no-flatten attrs', async function() {
+ const spyRemoveGesture = vi.spyOn(globalThis, '__RemoveGestureDetector');
+ let _gesture = {
+ id: 1,
+ type: 0,
+ callbacks: {
+ onUpdate: {
+ _wkltId: 'bdd4:dd564:2',
+ },
+ },
+ __isGesture: true,
+ toJSON: function() {
+ return {
+ ...this,
+ __isSerialized: true,
+ };
+ },
+ };
+ let keepGesture = true;
+
+ function Comp() {
+ const props = keepGesture
+ ? {
+ 'clip-radius': 8,
+ 'main-thread:gesture': _gesture,
+ }
+ : {
+ 'clip-radius': 8,
+ };
+ return (
+
+ 1
+
+ );
+ }
+
+ {
+ __root.__jsx = ;
+ renderPage();
+ }
+
+ {
+ globalEnvManager.switchToBackground();
+ render(, __root);
+ }
+
+ {
+ lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]);
+
+ globalEnvManager.switchToMainThread();
+ const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0];
+ globalThis[rLynxChange[0]](rLynxChange[1]);
+ }
+
+ {
+ globalEnvManager.switchToBackground();
+ lynx.getNativeApp().callLepusMethod.mockClear();
+ spyRemoveGesture.mockClear();
+ keepGesture = false;
+
+ render(, __root);
+
+ globalEnvManager.switchToMainThread();
+ const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0];
+ globalThis[rLynxChange[0]](rLynxChange[1]);
+ const textElement = __root.__element_root.children[0].children[0];
+
+ expect(spyRemoveGesture).toHaveBeenCalledTimes(1);
+ expect(spyRemoveGesture).toHaveBeenCalledWith(textElement, 1);
+ expect(textElement.props['clip-radius']).toBe(8);
+ expect(textElement.props.flatten).toBe(false);
+ expect(textElement.props['has-react-gesture']).toBeUndefined();
+ expect(textElement.props.gesture).toBeUndefined();
+ }
+ });
it('remove stale detector ids when gesture count shrinks on diff', async function() {
const spySetGesture = vi.spyOn(globalThis, '__SetGestureDetector');
const spyRemoveGesture = vi.spyOn(globalThis, '__RemoveGestureDetector');
diff --git a/packages/react/runtime/__test__/snapshot/gesture/prepareGestureForCommit.test.js b/packages/react/runtime/__test__/snapshot/gesture/prepareGestureForCommit.test.js
index 5566bc5e84..1d5ebf9e01 100644
--- a/packages/react/runtime/__test__/snapshot/gesture/prepareGestureForCommit.test.js
+++ b/packages/react/runtime/__test__/snapshot/gesture/prepareGestureForCommit.test.js
@@ -1,8 +1,22 @@
-import { describe, expect, it } from 'vitest';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-import { prepareGestureForCommit } from '../../../src/snapshot/gesture/processGestureBagkround.js';
+import { prepareGestureForCommit } from '../../../src/snapshot/gesture/processGestureBagkround';
+import { clearConfigCacheForTesting } from '../../../src/snapshot/worklet/functionality';
describe('prepareGestureForCommit', () => {
+ let previousSdkVersion;
+
+ beforeEach(() => {
+ previousSdkVersion = SystemInfo.lynxSdkVersion;
+ SystemInfo.lynxSdkVersion = '2.14';
+ clearConfigCacheForTesting();
+ });
+
+ afterEach(() => {
+ SystemInfo.lynxSdkVersion = previousSdkVersion;
+ clearConfigCacheForTesting();
+ });
+
it('does not mutate input gesture and supports non-object callbacks', () => {
const gesture = {
id: 1,
@@ -25,10 +39,48 @@ describe('prepareGestureForCommit', () => {
expect(committed.callbacks).not.toBe(gesture.callbacks);
expect(committed.callbacks.onUpdate).toBe(null);
- expect(committed.toJSON).toBe(gesture.toJSON);
+ expect(committed.toJSON).not.toBe(gesture.toJSON);
// Gesture runtime provides toJSON; ensure the committed object still serializes.
const json = committed.toJSON();
expect(json.__isSerialized).toBe(true);
});
+
+ it('serializes committed callbacks even when the source toJSON closes over the original gesture', () => {
+ const gesture = {
+ id: 1,
+ type: 0,
+ callbacks: {
+ onUpdate: {
+ _wkltId: 'bdd4:dd564:2',
+ },
+ },
+ waitFor: [],
+ simultaneousWith: [],
+ continueWith: [],
+ __isGesture: true,
+ };
+ gesture.toJSON = () => ({
+ id: gesture.id,
+ type: gesture.type,
+ callbacks: gesture.callbacks,
+ waitFor: [],
+ simultaneousWith: [],
+ continueWith: [],
+ __isSerialized: true,
+ });
+
+ const committed = prepareGestureForCommit(gesture);
+ const json = committed.toJSON();
+
+ expect(committed.callbacks.onUpdate).toEqual({
+ _wkltId: 'bdd4:dd564:2',
+ });
+ expect(committed.callbacks.onUpdate).not.toBe(gesture.callbacks.onUpdate);
+ expect(json.callbacks.onUpdate).toEqual({
+ _wkltId: 'bdd4:dd564:2',
+ });
+ expect(json.callbacks.onUpdate).not.toBe(gesture.callbacks.onUpdate);
+ expect(json.callbacks.onUpdate).toBe(committed.callbacks.onUpdate);
+ });
});
diff --git a/packages/react/runtime/__test__/snapshot/gesture/processGesture.test.ts b/packages/react/runtime/__test__/snapshot/gesture/processGesture.test.ts
index 2d29953446..05d0c8ef6c 100644
--- a/packages/react/runtime/__test__/snapshot/gesture/processGesture.test.ts
+++ b/packages/react/runtime/__test__/snapshot/gesture/processGesture.test.ts
@@ -150,6 +150,8 @@ describe('processGesture', () => {
const removedIds = removeGestureDetector.mock.calls.map(([, id]) => id).sort((a, b) => a - b);
expect(removedIds).toEqual([2]);
expect(setAttribute).toHaveBeenCalledWith(dom, 'has-react-gesture', null);
+ expect(setAttribute).toHaveBeenCalledWith(dom, 'gesture', null);
+ expect(setAttribute).not.toHaveBeenCalledWith(dom, 'flatten', null);
});
it('deduplicates same-id gestures in composed gesture diff', () => {
@@ -201,6 +203,35 @@ describe('processGesture', () => {
const removedIds = removeGestureDetector.mock.calls.map(([, id]) => id).sort((a, b) => a - b);
expect(removedIds).toEqual([1, 2]);
expect(setAttribute).toHaveBeenCalledWith(dom, 'has-react-gesture', null);
+ expect(setAttribute).toHaveBeenCalledWith(dom, 'gesture', null);
+ expect(setAttribute).not.toHaveBeenCalledWith(dom, 'flatten', null);
+ });
+
+ it('clears legacy gesture state without flatten when composed gesture serializes to no base gestures', () => {
+ const dom = {} as FiberElement;
+ const gestureA = createSerializedGesture(1);
+ const gestureB = createSerializedGesture(2);
+ const oldComposed = createSerializedComposedGesture([gestureA, gestureB]);
+ const invalidComposed = createSerializedComposedGesture([
+ {
+ type: 0,
+ } as any,
+ ]);
+
+ processGesture(dom, oldComposed as any, undefined, false);
+ setAttribute.mockClear();
+ setGestureDetector.mockClear();
+ removeGestureDetector.mockClear();
+
+ processGesture(dom, invalidComposed as any, oldComposed as any, false);
+
+ expect(setGestureDetector).not.toHaveBeenCalled();
+ expect(removeGestureDetector).toHaveBeenCalledTimes(2);
+ expect(removeGestureDetector).toHaveBeenNthCalledWith(1, dom, 1);
+ expect(removeGestureDetector).toHaveBeenNthCalledWith(2, dom, 2);
+ expect(setAttribute).toHaveBeenCalledWith(dom, 'has-react-gesture', null);
+ expect(setAttribute).toHaveBeenCalledWith(dom, 'gesture', null);
+ expect(setAttribute).not.toHaveBeenCalledWith(dom, 'flatten', null);
});
it('removes stale detector ids before setting when gesture count shrinks on diff', () => {
diff --git a/packages/react/runtime/__test__/snapshot/mtf-execid-churn.test.jsx b/packages/react/runtime/__test__/snapshot/mtf-execid-churn.test.jsx
index 299a1ef6fb..cf167ff688 100644
--- a/packages/react/runtime/__test__/snapshot/mtf-execid-churn.test.jsx
+++ b/packages/react/runtime/__test__/snapshot/mtf-execid-churn.test.jsx
@@ -2,12 +2,12 @@ import { options, render } from 'preact';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { useState } from '../../src/index';
-import { replaceCommitHook } from '../../src/lifecycle/patch/commit';
-import { injectUpdateMainThread } from '../../src/lifecycle/patch/updateMainThread';
+import { replaceCommitHook } from '../../src/snapshot/lifecycle/patch/commit';
+import { injectUpdateMainThread } from '../../src/snapshot/lifecycle/patch/updateMainThread';
import { __root } from '../../src/root';
import { setupPage } from '../../src/snapshot';
-import { globalEnvManager } from '../utils/envManager';
-import { elementTree, waitSchedule } from '../utils/nativeMethod';
+import { globalEnvManager } from './utils/envManager';
+import { elementTree, waitSchedule } from './utils/nativeMethod';
let prevLynxSdkVersion;
let prevCommit;
diff --git a/packages/react/runtime/src/snapshot/gesture/processGesture.ts b/packages/react/runtime/src/snapshot/gesture/processGesture.ts
index 1169fb806d..8874519be7 100644
--- a/packages/react/runtime/src/snapshot/gesture/processGesture.ts
+++ b/packages/react/runtime/src/snapshot/gesture/processGesture.ts
@@ -112,6 +112,13 @@ function removeGestureDetector(dom: FiberElement, id: number): void {
}
}
+function clearLegacyGestureState(dom: FiberElement): void {
+ __SetAttribute(dom, 'has-react-gesture', null);
+ // `flatten` may still be required by unrelated attrs from the same spread
+ // (e.g. `clip-radius`), so only clear the gesture-specific legacy state here.
+ __SetAttribute(dom, 'gesture', null);
+}
+
function getGestureInfo(
gesture: BaseGesture,
oldGesture: BaseGesture | undefined,
@@ -170,9 +177,7 @@ export function processGesture(
// Clearing the attrs keeps the legacy main-thread state in sync when
// gesture props disappear during spread/key-removal updates.
if (!domSet && oldBaseGesturesById.size > 0) {
- __SetAttribute(dom, 'has-react-gesture', null);
- __SetAttribute(dom, 'flatten', null);
- __SetAttribute(dom, 'gesture', null);
+ clearLegacyGestureState(dom);
}
return;
}
@@ -212,8 +217,8 @@ export function processGesture(
removeGestureDetector(dom, oldBaseGesture.id);
}
- if (!domSet) {
- __SetAttribute(dom, 'has-react-gesture', null);
+ if (!domSet && oldBaseGesturesById.size > 0) {
+ clearLegacyGestureState(dom);
}
return;
}
diff --git a/packages/react/runtime/src/snapshot/gesture/processGestureBagkround.ts b/packages/react/runtime/src/snapshot/gesture/processGestureBagkround.ts
index 72b3c4ccf3..1bf7d0584d 100644
--- a/packages/react/runtime/src/snapshot/gesture/processGestureBagkround.ts
+++ b/packages/react/runtime/src/snapshot/gesture/processGestureBagkround.ts
@@ -14,6 +14,47 @@ function prepareWorkletForCommit(value: Worklet): Worklet | null {
return onPostWorkletCtx(copy);
}
+function removeUndefinedFields(record: Record): Record {
+ const filteredEntries = Object.entries(record).filter(([, value]) => value !== undefined);
+ return Object.fromEntries(filteredEntries);
+}
+
+function serializeCommittedGesture(gesture: GestureKind): Record {
+ if (gesture.type === GestureTypeInner.COMPOSED) {
+ const composed = gesture as ComposedGesture;
+ return {
+ type: composed.type,
+ gestures: composed.gestures.map((subGesture) => serializeCommittedGesture(subGesture)),
+ __isSerialized: true,
+ };
+ }
+
+ const baseGesture = gesture as BaseGesture;
+ return removeUndefinedFields({
+ config: baseGesture.config,
+ id: baseGesture.id,
+ type: baseGesture.type,
+ simultaneousWith: baseGesture.simultaneousWith?.map(subGesture => ({
+ id: subGesture.id,
+ })) ?? [],
+ waitFor: baseGesture.waitFor?.map(subGesture => ({ id: subGesture.id })) ?? [],
+ continueWith: baseGesture.continueWith?.map(subGesture => ({
+ id: subGesture.id,
+ })) ?? [],
+ callbacks: baseGesture.callbacks,
+ __isSerialized: true,
+ });
+}
+
+function attachCommittedSerializer(gesture: TGesture): TGesture {
+ const serialize = () => serializeCommittedGesture(gesture);
+
+ return Object.assign(gesture as Record, {
+ serialize,
+ toJSON: serialize,
+ }) as TGesture;
+}
+
/**
* Prepare a gesture payload to be sent to the main thread.
*
@@ -28,7 +69,7 @@ export function prepareGestureForCommit(gesture: GestureKind): GestureKind {
...composed,
gestures: composed.gestures.map((g) => prepareGestureForCommit(g)),
};
- return committed;
+ return attachCommittedSerializer(committed);
}
const baseGesture = gesture as BaseGesture;
@@ -49,5 +90,5 @@ export function prepareGestureForCommit(gesture: GestureKind): GestureKind {
...baseGesture,
callbacks: committedCallbacks,
};
- return committed;
+ return attachCommittedSerializer(committed);
}
diff --git a/packages/react/runtime/src/snapshot/snapshot/backgroundSnapshot.ts b/packages/react/runtime/src/snapshot/snapshot/backgroundSnapshot.ts
index 7bfdb5ebe3..7b34699ee0 100644
--- a/packages/react/runtime/src/snapshot/snapshot/backgroundSnapshot.ts
+++ b/packages/react/runtime/src/snapshot/snapshot/backgroundSnapshot.ts
@@ -114,22 +114,29 @@ function prepareSpreadForCommit(
spread: Record,
oldSpread: Record | undefined,
): Record {
- const committed: Record = { ...spread };
- for (const key in committed) {
- const v = committed[key];
+ let committed: Record | undefined;
+ for (const key in spread) {
+ const v = spread[key];
if (key === '__lynx_timing_flag' && oldSpread?.[key] != v && globalPipelineOptions) {
globalPipelineOptions.needTimestamps = true;
}
if (!v || typeof v !== 'object') {
continue;
}
- if ('_wkltId' in (v as Record)) {
- committed[key] = prepareWorkletForCommit(v as Worklet);
- } else if ('__isGesture' in (v as Record)) {
- committed[key] = prepareGestureForCommit(v as GestureKind);
+ const valueRecord = v as Record;
+ let committedValue: unknown;
+ if ('_wkltId' in valueRecord) {
+ committedValue = prepareWorkletForCommit(v as Worklet);
+ } else if ('__isGesture' in valueRecord) {
+ committedValue = prepareGestureForCommit(v as GestureKind);
+ } else {
+ continue;
}
+
+ committed ??= { ...spread };
+ committed[key] = committedValue;
}
- return committed;
+ return committed ?? spread;
}
export class BackgroundSnapshotInstance {
From 4a331fa3019bcabe2af76f75b5c23958ec6b931c Mon Sep 17 00:00:00 2001
From: yradex <11014207+Yradex@users.noreply.github.com>
Date: Mon, 20 Apr 2026 16:02:12 +0800
Subject: [PATCH 5/6] style: format gesture snapshot test
---
.../react/runtime/__test__/snapshot/gesture.test.jsx | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/packages/react/runtime/__test__/snapshot/gesture.test.jsx b/packages/react/runtime/__test__/snapshot/gesture.test.jsx
index af73da4409..fadb8eab72 100644
--- a/packages/react/runtime/__test__/snapshot/gesture.test.jsx
+++ b/packages/react/runtime/__test__/snapshot/gesture.test.jsx
@@ -1147,12 +1147,12 @@ describe('Gesture in spread', () => {
function Comp() {
const props = keepGesture
? {
- 'clip-radius': 8,
- 'main-thread:gesture': _gesture,
- }
+ 'clip-radius': 8,
+ 'main-thread:gesture': _gesture,
+ }
: {
- 'clip-radius': 8,
- };
+ 'clip-radius': 8,
+ };
return (
1
From 0ec04e761b0612685f4379c6e270952fff79be32 Mon Sep 17 00:00:00 2001
From: yradex <11014207+Yradex@users.noreply.github.com>
Date: Mon, 20 Apr 2026 16:30:25 +0800
Subject: [PATCH 6/6] fix: avoid clobbering gesture attrs on removal
---
.../snapshot/gesture/processGesture.test.ts | 32 +++++++++++++++++--
.../src/snapshot/gesture/processGesture.ts | 6 +++-
2 files changed, 35 insertions(+), 3 deletions(-)
diff --git a/packages/react/runtime/__test__/snapshot/gesture/processGesture.test.ts b/packages/react/runtime/__test__/snapshot/gesture/processGesture.test.ts
index 05d0c8ef6c..1e63c8c05d 100644
--- a/packages/react/runtime/__test__/snapshot/gesture/processGesture.test.ts
+++ b/packages/react/runtime/__test__/snapshot/gesture/processGesture.test.ts
@@ -150,7 +150,7 @@ describe('processGesture', () => {
const removedIds = removeGestureDetector.mock.calls.map(([, id]) => id).sort((a, b) => a - b);
expect(removedIds).toEqual([2]);
expect(setAttribute).toHaveBeenCalledWith(dom, 'has-react-gesture', null);
- expect(setAttribute).toHaveBeenCalledWith(dom, 'gesture', null);
+ expect(setAttribute).not.toHaveBeenCalledWith(dom, 'gesture', null);
expect(setAttribute).not.toHaveBeenCalledWith(dom, 'flatten', null);
});
@@ -203,7 +203,7 @@ describe('processGesture', () => {
const removedIds = removeGestureDetector.mock.calls.map(([, id]) => id).sort((a, b) => a - b);
expect(removedIds).toEqual([1, 2]);
expect(setAttribute).toHaveBeenCalledWith(dom, 'has-react-gesture', null);
- expect(setAttribute).toHaveBeenCalledWith(dom, 'gesture', null);
+ expect(setAttribute).not.toHaveBeenCalledWith(dom, 'gesture', null);
expect(setAttribute).not.toHaveBeenCalledWith(dom, 'flatten', null);
});
@@ -229,6 +229,34 @@ describe('processGesture', () => {
expect(removeGestureDetector).toHaveBeenCalledTimes(2);
expect(removeGestureDetector).toHaveBeenNthCalledWith(1, dom, 1);
expect(removeGestureDetector).toHaveBeenNthCalledWith(2, dom, 2);
+ expect(setAttribute).toHaveBeenCalledWith(dom, 'has-react-gesture', null);
+ expect(setAttribute).not.toHaveBeenCalledWith(dom, 'gesture', null);
+ expect(setAttribute).not.toHaveBeenCalledWith(dom, 'flatten', null);
+ });
+
+ it('falls back to clearing gesture attr when remove API is unavailable', () => {
+ const dom = {} as FiberElement;
+ const gesture = createSerializedGesture(1);
+
+ vi.unstubAllGlobals();
+ setAttribute = vi.fn();
+ setGestureDetector = vi.fn();
+ hydrateCtx = vi.fn();
+ vi.stubGlobal('__SetAttribute', setAttribute);
+ vi.stubGlobal('__SetGestureDetector', setGestureDetector);
+ vi.stubGlobal('__RemoveGestureDetector', undefined);
+ vi.stubGlobal('lynxWorkletImpl', {
+ _hydrateCtx: hydrateCtx,
+ _jsFunctionLifecycleManager: {
+ addRef: vi.fn(),
+ },
+ _eventDelayImpl: {
+ runDelayedWorklet: vi.fn(),
+ },
+ });
+
+ processGesture(dom, undefined as any, gesture as any, false);
+
expect(setAttribute).toHaveBeenCalledWith(dom, 'has-react-gesture', null);
expect(setAttribute).toHaveBeenCalledWith(dom, 'gesture', null);
expect(setAttribute).not.toHaveBeenCalledWith(dom, 'flatten', null);
diff --git a/packages/react/runtime/src/snapshot/gesture/processGesture.ts b/packages/react/runtime/src/snapshot/gesture/processGesture.ts
index 8874519be7..9d6e2a4cb3 100644
--- a/packages/react/runtime/src/snapshot/gesture/processGesture.ts
+++ b/packages/react/runtime/src/snapshot/gesture/processGesture.ts
@@ -116,7 +116,11 @@ function clearLegacyGestureState(dom: FiberElement): void {
__SetAttribute(dom, 'has-react-gesture', null);
// `flatten` may still be required by unrelated attrs from the same spread
// (e.g. `clip-radius`), so only clear the gesture-specific legacy state here.
- __SetAttribute(dom, 'gesture', null);
+ // When `__RemoveGestureDetector` is available, let it own the detector cleanup
+ // so we do not clobber an unrelated user-provided `gesture` attr.
+ if (typeof __RemoveGestureDetector !== 'function') {
+ __SetAttribute(dom, 'gesture', null);
+ }
}
function getGestureInfo(