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/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..fadb8eab72 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, }; }, @@ -1117,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 new file mode 100644 index 0000000000..1d5ebf9e01 --- /dev/null +++ b/packages/react/runtime/__test__/snapshot/gesture/prepareGestureForCommit.test.js @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +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, + 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); + + 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..1e63c8c05d 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).not.toHaveBeenCalledWith(dom, 'gesture', null); + expect(setAttribute).not.toHaveBeenCalledWith(dom, 'flatten', null); }); it('deduplicates same-id gestures in composed gesture diff', () => { @@ -201,6 +203,63 @@ 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).not.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).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); }); 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 new file mode 100644 index 0000000000..cf167ff688 --- /dev/null +++ b/packages/react/runtime/__test__/snapshot/mtf-execid-churn.test.jsx @@ -0,0 +1,216 @@ +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/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'; + +let prevLynxSdkVersion; +let prevCommit; + +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; +} + +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(); +}); + +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); + await waitSchedule(); + + 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..9d6e2a4cb3 100644 --- a/packages/react/runtime/src/snapshot/gesture/processGesture.ts +++ b/packages/react/runtime/src/snapshot/gesture/processGesture.ts @@ -112,6 +112,17 @@ 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. + // 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( gesture: BaseGesture, oldGesture: BaseGesture | undefined, @@ -161,6 +172,20 @@ 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) { + clearLegacyGestureState(dom); + } + return; + } + const { uniqOldBaseGestures, oldBaseGesturesById } = collectOldGestureInfo(oldGesture); // Fast path for the most common case: single base gesture update. @@ -196,8 +221,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 39badeeb44..1bf7d0584d 100644 --- a/packages/react/runtime/src/snapshot/gesture/processGestureBagkround.ts +++ b/packages/react/runtime/src/snapshot/gesture/processGestureBagkround.ts @@ -1,19 +1,94 @@ // 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 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) { - 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; + 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. + * + * 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) { + const composed = gesture as ComposedGesture; + const committed: ComposedGesture = { + ...composed, + gestures: composed.gestures.map((g) => prepareGestureForCommit(g)), + }; + return attachCommittedSerializer(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 = { + ...baseGesture, + callbacks: committedCallbacks, + }; + return attachCommittedSerializer(committed); } diff --git a/packages/react/runtime/src/snapshot/snapshot/backgroundSnapshot.ts b/packages/react/runtime/src/snapshot/snapshot/backgroundSnapshot.ts index 4d777daf4f..7b34699ee0 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,41 @@ 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 { + 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; + } + 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 ?? spread; +} + export class BackgroundSnapshotInstance { constructor(public type: string) { // Suspense uses 'div' @@ -389,33 +424,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 +502,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) {