Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/retain-offscreen-worklet-ctx.md
Original file line number Diff line number Diff line change
@@ -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.
133 changes: 133 additions & 0 deletions packages/react/runtime/__test__/snapshot/workletLifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>;

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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
`);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -24,13 +24,10 @@ describe('MTFObservers', () => {
addRef,
};

onWorkletCtxUpdate(
retainWorkletCtx(
{
_wkltId: 'ctx1',
},
undefined,
false,
'element',
);

expect(addRef).not.toHaveBeenCalled();
Expand All @@ -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();
});
});
20 changes: 19 additions & 1 deletion packages/react/runtime/src/snapshot/gesture/processGesture.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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()) {
Expand Down
13 changes: 10 additions & 3 deletions packages/react/runtime/src/snapshot/snapshot/gesture.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -13,12 +13,19 @@ export function updateGesture(
elementIndex: number,
workletType: string,
): void {
const value = snapshot.__values![expIndex] as GestureKind;
if (workletType === 'main-thread') {
Comment thread
Yradex marked this conversation as resolved.
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,
});
}
}
29 changes: 29 additions & 0 deletions packages/react/runtime/src/snapshot/snapshot/spread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -40,6 +43,31 @@ const noFlattenAttributes = /* @__PURE__ */ new Set<string>([
'exposure-id',
]);

function retainSpreadWorkletCtx(newValue: Record<string, unknown>, oldValue: Record<string, unknown>): 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,
Expand Down Expand Up @@ -84,6 +112,7 @@ function updateSpread(
}

if (!snapshot.__elements) {
retainSpreadWorkletCtx(newValue, oldValue);
return;
}

Expand Down
16 changes: 12 additions & 4 deletions packages/react/runtime/src/snapshot/snapshot/workletEvent.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 = {
Expand Down
Loading
Loading