From 4e36350ca33509c1b96af11066f37b6918cf1e6f Mon Sep 17 00:00:00 2001 From: yradex <11014207+Yradex@users.noreply.github.com> Date: Sat, 9 May 2026 20:04:15 +0800 Subject: [PATCH] fix(react): retain offscreen worklet ctx refs --- .changeset/retain-offscreen-worklet-ctx.md | 5 + .../snapshot/workletLifecycle.test.ts | 133 ++++++++++++++++++ .../jsFunctionLifecycle.test.js | 17 +++ .../worklet-runtime/observers.test.js | 28 +++- .../src/snapshot/gesture/processGesture.ts | 20 ++- .../runtime/src/snapshot/snapshot/gesture.ts | 13 +- .../runtime/src/snapshot/snapshot/spread.ts | 29 ++++ .../src/snapshot/snapshot/workletEvent.ts | 16 ++- .../src/snapshot/snapshot/workletRef.ts | 15 +- .../src/worklet-runtime/bindings/observers.ts | 9 +- .../worklet-runtime/jsFunctionLifecycle.ts | 5 + 11 files changed, 270 insertions(+), 20 deletions(-) create mode 100644 .changeset/retain-offscreen-worklet-ctx.md create mode 100644 packages/react/runtime/__test__/snapshot/workletLifecycle.test.ts diff --git a/.changeset/retain-offscreen-worklet-ctx.md b/.changeset/retain-offscreen-worklet-ctx.md new file mode 100644 index 0000000000..fbe5e81878 --- /dev/null +++ b/.changeset/retain-offscreen-worklet-ctx.md @@ -0,0 +1,5 @@ +--- +"@lynx-js/react": patch +--- + +Retain main-thread worklet context references before offscreen snapshot elements are materialized, so event, ref, gesture, and spread callbacks stay alive until the DOM update path can attach them. diff --git a/packages/react/runtime/__test__/snapshot/workletLifecycle.test.ts b/packages/react/runtime/__test__/snapshot/workletLifecycle.test.ts new file mode 100644 index 0000000000..87e6738f05 --- /dev/null +++ b/packages/react/runtime/__test__/snapshot/workletLifecycle.test.ts @@ -0,0 +1,133 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { updateGesture } from '../../src/snapshot/snapshot/gesture'; +import { updateSpread } from '../../src/snapshot/snapshot/spread'; +import { updateWorkletEvent } from '../../src/snapshot/snapshot/workletEvent'; +import { updateWorkletRef } from '../../src/snapshot/snapshot/workletRef'; + +function createSnapshot(value: unknown) { + return { + __id: 1, + __values: [value], + type: 'TestSnapshot', + } as any; +} + +describe('worklet lifecycle without elements', () => { + let addRef: ReturnType; + + beforeEach(() => { + addRef = vi.fn(); + globalThis.lynxWorkletImpl = { + _jsFunctionLifecycleManager: { + addRef, + }, + } as any; + }); + + afterEach(() => { + vi.restoreAllMocks(); + delete globalThis.lynxWorkletImpl; + }); + + it('retains main-thread event worklet ctx before elements are materialized', () => { + const worklet = { + _execId: 1, + _wkltId: 'event', + }; + + updateWorkletEvent(createSnapshot(worklet), 0, undefined as any, 0, 'main-thread', 'bindEvent', 'tap'); + + expect(addRef).toHaveBeenCalledTimes(1); + expect(addRef).toHaveBeenCalledWith(1, worklet); + }); + + it('retains main-thread ref worklet ctx before elements are materialized', () => { + const worklet = { + _execId: 2, + _wkltId: 'ref', + }; + + updateWorkletRef(createSnapshot(worklet), 0, undefined, 0, 'main-thread'); + + expect(addRef).toHaveBeenCalledTimes(1); + expect(addRef).toHaveBeenCalledWith(2, worklet); + }); + + it('retains main-thread gesture callbacks before elements are materialized', () => { + const callback = { + _execId: 3, + _wkltId: 'gesture', + }; + const gesture = { + __isSerialized: true, + callbacks: { + onUpdate: callback, + }, + id: 1, + type: 0, + }; + + updateGesture(createSnapshot(gesture), 0, undefined, 0, 'main-thread'); + + expect(addRef).toHaveBeenCalledTimes(1); + expect(addRef).toHaveBeenCalledWith(3, callback); + }); + + it('retains main-thread spread worklet ctx before elements are materialized', () => { + const eventWorklet = { + _execId: 4, + _wkltId: 'spread-event', + }; + const refWorklet = { + _execId: 5, + _wkltId: 'spread-ref', + }; + const gestureCallback = { + _execId: 6, + _wkltId: 'spread-gesture', + }; + const gesture = { + __isSerialized: true, + callbacks: { + onUpdate: gestureCallback, + }, + id: 1, + type: 0, + }; + + updateSpread( + createSnapshot({ + 'main-thread:bindtap': eventWorklet, + 'main-thread:gesture': gesture, + 'main-thread:ref': refWorklet, + }), + 0, + {}, + 0, + ); + + expect(addRef.mock.calls).toEqual([ + [4, eventWorklet], + [6, gestureCallback], + [5, refWorklet], + ]); + }); + + it('does not retain unchanged spread worklet ctx again before elements are materialized', () => { + const eventWorklet = { + _execId: 7, + _wkltId: 'spread-event', + }; + const spread = { + 'main-thread:bindtap': eventWorklet, + }; + + updateSpread(createSnapshot(spread), 0, spread, 0); + + expect(addRef).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react/runtime/__test__/worklet-runtime/jsFunctionLifecycle.test.js b/packages/react/runtime/__test__/worklet-runtime/jsFunctionLifecycle.test.js index 87615432ad..26df52b8ee 100644 --- a/packages/react/runtime/__test__/worklet-runtime/jsFunctionLifecycle.test.js +++ b/packages/react/runtime/__test__/worklet-runtime/jsFunctionLifecycle.test.js @@ -69,4 +69,21 @@ describe('jsFunctionLifecycle', () => { } `); }); + + it('should skip duplicated addRef() for the same object', () => { + const manager = new JsFunctionLifecycleManager(); + const target = {}; + manager.addRef(3, target); + manager.addRef(3, target); + manager.removeRef(3); + manager.fire(); + expect(events[0]).toMatchInlineSnapshot(` + { + "data": [ + 3, + ], + "type": "Lynx.Worklet.releaseBackgroundWorkletCtx", + } + `); + }); }); diff --git a/packages/react/runtime/__test__/worklet-runtime/observers.test.js b/packages/react/runtime/__test__/worklet-runtime/observers.test.js index 9164e4609e..4b83347419 100644 --- a/packages/react/runtime/__test__/worklet-runtime/observers.test.js +++ b/packages/react/runtime/__test__/worklet-runtime/observers.test.js @@ -3,7 +3,7 @@ // LICENSE file in the root directory of this source tree. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { onWorkletCtxUpdate } from '../../src/worklet-runtime/bindings/observers'; +import { onWorkletCtxUpdate, retainWorkletCtx } from '../../src/worklet-runtime/bindings/observers'; import { initWorklet } from '../../src/worklet-runtime/workletRuntime'; beforeEach(() => { @@ -24,13 +24,10 @@ describe('MTFObservers', () => { addRef, }; - onWorkletCtxUpdate( + retainWorkletCtx( { _wkltId: 'ctx1', }, - undefined, - false, - 'element', ); expect(addRef).not.toHaveBeenCalled(); @@ -46,8 +43,27 @@ describe('MTFObservers', () => { _execId: 8, }; - onWorkletCtxUpdate(mtf, undefined, false, 'element'); + retainWorkletCtx(mtf); expect(addRef).toHaveBeenCalledWith(8, mtf); }); + + it('should not add lifecycle refs during element updates', () => { + const addRef = vi.fn(); + globalThis.lynxWorkletImpl._jsFunctionLifecycleManager = { + addRef, + }; + + onWorkletCtxUpdate( + { + _wkltId: 'ctx1', + _execId: 8, + }, + undefined, + false, + 'element', + ); + + expect(addRef).not.toHaveBeenCalled(); + }); }); diff --git a/packages/react/runtime/src/snapshot/gesture/processGesture.ts b/packages/react/runtime/src/snapshot/gesture/processGesture.ts index 9d6e2a4cb3..fd4ae6acec 100644 --- a/packages/react/runtime/src/snapshot/gesture/processGesture.ts +++ b/packages/react/runtime/src/snapshot/gesture/processGesture.ts @@ -1,7 +1,7 @@ // Copyright 2024 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 { onWorkletCtxUpdate } from '@lynx-js/react/worklet-runtime/bindings'; +import { onWorkletCtxUpdate, retainWorkletCtx } from '@lynx-js/react/worklet-runtime/bindings'; import { GestureTypeInner } from './types.js'; import type { BaseGesture, ComposedGesture, GestureConfig, GestureKind } from './types.js'; @@ -105,6 +105,20 @@ function consumeOldBaseGesture( return fallbackOldBaseGesture; } +export function retainGestureWorkletCtx(gesture: GestureKind | undefined): void { + const retainedBaseGestures: BaseGesture[] = []; + appendUniqueSerializedBaseGestures(gesture, retainedBaseGestures, new Set()); + + for (const baseGesture of retainedBaseGestures) { + for (const key of Object.keys(baseGesture.callbacks)) { + const callback = baseGesture.callbacks[key]; + if (callback) { + retainWorkletCtx(callback); + } + } + } +} + function removeGestureDetector(dom: FiberElement, id: number): void { // Keep compatibility with old runtimes where remove API is not exposed. if (typeof __RemoveGestureDetector === 'function') { @@ -169,9 +183,13 @@ export function processGesture( isFirstScreen: boolean, gestureOptions?: { domSet: boolean; + retainCallbacks?: boolean; }, ): void { const domSet = gestureOptions?.domSet === true; + if (gestureOptions?.retainCallbacks !== false) { + retainGestureWorkletCtx(gesture); + } if (!gesture || !isSerializedGesture(gesture)) { const { oldBaseGesturesById } = collectOldGestureInfo(oldGesture); for (const oldBaseGesture of oldBaseGesturesById.values()) { diff --git a/packages/react/runtime/src/snapshot/snapshot/gesture.ts b/packages/react/runtime/src/snapshot/snapshot/gesture.ts index b59cd9fe5b..57c6f083e2 100644 --- a/packages/react/runtime/src/snapshot/snapshot/gesture.ts +++ b/packages/react/runtime/src/snapshot/snapshot/gesture.ts @@ -1,7 +1,7 @@ // 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 { processGesture } from '../gesture/processGesture.js'; +import { processGesture, retainGestureWorkletCtx } from '../gesture/processGesture.js'; import type { GestureKind } from '../gesture/types.js'; import { isMainThreadHydrating } from '../lifecycle/patch/isMainThreadHydrating.js'; import type { SnapshotInstance } from '../snapshot/snapshot.js'; @@ -13,12 +13,19 @@ export function updateGesture( elementIndex: number, workletType: string, ): void { + const value = snapshot.__values![expIndex] as GestureKind; + if (workletType === 'main-thread') { + retainGestureWorkletCtx(value); + } + if (!snapshot.__elements) { return; } - const value = snapshot.__values![expIndex] as GestureKind; if (workletType === 'main-thread') { - processGesture(snapshot.__elements[elementIndex]!, value, oldValue as GestureKind, isMainThreadHydrating); + processGesture(snapshot.__elements[elementIndex]!, value, oldValue as GestureKind, isMainThreadHydrating, { + domSet: false, + retainCallbacks: false, + }); } } diff --git a/packages/react/runtime/src/snapshot/snapshot/spread.ts b/packages/react/runtime/src/snapshot/snapshot/spread.ts index bec08fe195..4d958a523e 100644 --- a/packages/react/runtime/src/snapshot/snapshot/spread.ts +++ b/packages/react/runtime/src/snapshot/snapshot/spread.ts @@ -9,6 +9,7 @@ * optimized attribute updates at compile time, avoiding runtime object spreads. */ +import { retainWorkletCtx } from '@lynx-js/react/worklet-runtime/bindings'; import type { Element, Worklet, WorkletRefImpl } from '@lynx-js/react/worklet-runtime/bindings'; import type { BackgroundSnapshotInstance } from './backgroundSnapshot.js'; @@ -19,6 +20,8 @@ import { transformRef, updateRef } from './ref.js'; import { updateWorkletEvent } from './workletEvent.js'; import { updateWorkletRef } from './workletRef.js'; import { isDirectOrDeepEqual, isEmptyObject, pick } from '../../utils.js'; +import { retainGestureWorkletCtx } from '../gesture/processGesture.js'; +import type { GestureKind } from '../gesture/types.js'; import { ListUpdateInfoRecording } from '../list/listUpdateInfo.js'; import { __pendingListUpdates } from '../list/pendingListUpdates.js'; import type { SnapshotInstance } from '../snapshot/snapshot.js'; @@ -40,6 +43,31 @@ const noFlattenAttributes = /* @__PURE__ */ new Set([ 'exposure-id', ]); +function retainSpreadWorkletCtx(newValue: Record, oldValue: Record): void { + let match: RegExpMatchArray | null = null; + for (const key in newValue) { + const value = newValue[key]; + if (value === oldValue[key]) { + continue; + } + + if (key.endsWith(':ref')) { + if (key.slice(0, -4) === 'main-thread' && value && (value as Worklet)._wkltId) { + retainWorkletCtx(value as Worklet); + } + } else if (key.endsWith(':gesture')) { + if (key.slice(0, -8) === 'main-thread') { + retainGestureWorkletCtx(value as GestureKind); + } + } else if ( + (match = eventRegExp.exec(key)) && match[2] === 'main-thread' && value !== null && value !== undefined + && typeof value === 'object' + ) { + retainWorkletCtx(value as Worklet); + } + } +} + function updateSpread( snapshot: SnapshotInstance, index: number, @@ -84,6 +112,7 @@ function updateSpread( } if (!snapshot.__elements) { + retainSpreadWorkletCtx(newValue, oldValue); return; } diff --git a/packages/react/runtime/src/snapshot/snapshot/workletEvent.ts b/packages/react/runtime/src/snapshot/snapshot/workletEvent.ts index 9306374754..448529e277 100644 --- a/packages/react/runtime/src/snapshot/snapshot/workletEvent.ts +++ b/packages/react/runtime/src/snapshot/snapshot/workletEvent.ts @@ -1,7 +1,7 @@ // Copyright 2024 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 { onWorkletCtxUpdate } from '@lynx-js/react/worklet-runtime/bindings'; +import { onWorkletCtxUpdate, retainWorkletCtx } from '@lynx-js/react/worklet-runtime/bindings'; import type { Worklet } from '@lynx-js/react/worklet-runtime/bindings'; import { describeInvalidValue } from '../debug/describeInvalidValue.js'; @@ -41,17 +41,25 @@ function updateWorkletEvent( eventType: string, eventName: string, ): void { - if (!snapshot.__elements) { - return; - } const rawValue = snapshot.__values![expIndex]; if (__DEV__ && rawValue !== null && rawValue !== undefined && typeof rawValue !== 'object') { + if (!snapshot.__elements) { + return; + } reportInvalidWorkletValue(snapshot, elementIndex, workletType, eventType, eventName, rawValue); return; } const value = (rawValue ?? {}) as Worklet; value._workletType = workletType; + if (workletType === 'main-thread') { + retainWorkletCtx(value); + } + + if (!snapshot.__elements) { + return; + } + if (workletType === 'main-thread') { onWorkletCtxUpdate(value, oldValue, isMainThreadHydrating, snapshot.__elements[elementIndex]!); const event = { diff --git a/packages/react/runtime/src/snapshot/snapshot/workletRef.ts b/packages/react/runtime/src/snapshot/snapshot/workletRef.ts index 293806d5fb..57cc98a49d 100644 --- a/packages/react/runtime/src/snapshot/snapshot/workletRef.ts +++ b/packages/react/runtime/src/snapshot/snapshot/workletRef.ts @@ -2,7 +2,12 @@ // 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 { onWorkletCtxUpdate, runWorkletCtx, updateWorkletRef as update } from '@lynx-js/react/worklet-runtime/bindings'; +import { + onWorkletCtxUpdate, + retainWorkletCtx, + runWorkletCtx, + updateWorkletRef as update, +} from '@lynx-js/react/worklet-runtime/bindings'; import type { Element, Worklet, WorkletRefImpl } from '@lynx-js/react/worklet-runtime/bindings'; import { isMainThreadHydrating } from '../lifecycle/patch/isMainThreadHydrating.js'; @@ -45,8 +50,13 @@ export function updateWorkletRef( expIndex: number, oldValue: WorkletRefImpl | Worklet | null | undefined, elementIndex: number, - _workletType: string, + workletType: string, ): void { + const value = snapshot.__values![expIndex] as (WorkletRefImpl | Worklet | undefined); + if (workletType === 'main-thread' && value && (value as Worklet)._wkltId) { + retainWorkletCtx(value as Worklet); + } + if (!snapshot.__elements) { return; } @@ -56,7 +66,6 @@ export function updateWorkletRef( snapshot.__worklet_ref_set?.delete(oldValue); } - const value = snapshot.__values![expIndex] as (WorkletRefImpl | Worklet | undefined); if (value === null || value === undefined) { // do nothing } else if (value._wvid) { diff --git a/packages/react/runtime/src/worklet-runtime/bindings/observers.ts b/packages/react/runtime/src/worklet-runtime/bindings/observers.ts index 7f39ea9f30..dcb6e78e3e 100644 --- a/packages/react/runtime/src/worklet-runtime/bindings/observers.ts +++ b/packages/react/runtime/src/worklet-runtime/bindings/observers.ts @@ -18,9 +18,6 @@ export function onWorkletCtxUpdate( isFirstScreen: boolean, element: ElementNode, ): void { - if (worklet._execId !== undefined) { - globalThis.lynxWorkletImpl?._jsFunctionLifecycleManager?.addRef(worklet._execId, worklet); - } if (isFirstScreen && oldWorklet) { globalThis.lynxWorkletImpl?._hydrateCtx(worklet, oldWorklet); } @@ -30,6 +27,12 @@ export function onWorkletCtxUpdate( } } +export function retainWorkletCtx(worklet: Worklet): void { + if (worklet._execId !== undefined) { + globalThis.lynxWorkletImpl?._jsFunctionLifecycleManager?.addRef(worklet._execId, worklet); + } +} + /** * This must be called when the hydration is finished. */ diff --git a/packages/react/runtime/src/worklet-runtime/jsFunctionLifecycle.ts b/packages/react/runtime/src/worklet-runtime/jsFunctionLifecycle.ts index b4e50f9742..97a70c6d94 100644 --- a/packages/react/runtime/src/worklet-runtime/jsFunctionLifecycle.ts +++ b/packages/react/runtime/src/worklet-runtime/jsFunctionLifecycle.ts @@ -14,6 +14,7 @@ import { isSdkVersionGt } from './utils/version.js'; class JsFunctionLifecycleManager { private execIdRefCount = new Map(); private execIdSetToFire = new Set(); + private retainedObjects = new WeakSet(); private willFire = false; private registry?: FinalizationRegistry = undefined; @@ -22,6 +23,10 @@ class JsFunctionLifecycleManager { } addRef(execId: number, objToRef: object): void { + if (this.retainedObjects.has(objToRef)) { + return; + } + this.retainedObjects.add(objToRef); this.execIdRefCount.set( execId, (this.execIdRefCount.get(execId) ?? 0) + 1,