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(