diff --git a/packages/react/testing-library/src/__tests__/worklet.test.jsx b/packages/react/testing-library/src/__tests__/worklet.test.jsx
index ac02d55fac..d1d50ac624 100644
--- a/packages/react/testing-library/src/__tests__/worklet.test.jsx
+++ b/packages/react/testing-library/src/__tests__/worklet.test.jsx
@@ -551,4 +551,68 @@ describe('worklet', () => {
`);
vi.resetAllMocks();
});
+
+ it('multiple main-thread worklets should work together when background thread is enabled', () => {
+ vi.spyOn(lynx.getNativeApp(), 'callLepusMethod');
+ const callLepusMethodCalls = lynx.getNativeApp().callLepusMethod.mock.calls;
+ expect(callLepusMethodCalls).toMatchInlineSnapshot(`[]`);
+
+ globalThis.firstCb = vi.fn();
+ globalThis.secondCb = vi.fn();
+
+ const Comp = () => {
+ return (
+
+ {
+ 'main thread';
+ globalThis.firstCb(event.key);
+ }}
+ >
+ first
+
+ {
+ 'main thread';
+ globalThis.secondCb(event.key);
+ }}
+ >
+ second
+
+
+ );
+ };
+
+ const { container, getByText } = render(, {
+ enableMainThread: true,
+ enableBackgroundThread: true,
+ });
+
+ expect(callLepusMethodCalls).toHaveLength(1);
+ expect(callLepusMethodCalls[0][0]).toBe('rLynxChange');
+
+ const patchData = JSON.parse(callLepusMethodCalls[0][1].data);
+ const workletIds = patchData.patchList
+ .flatMap(item => Array.isArray(item.snapshotPatch) ? item.snapshotPatch : [])
+ .filter(item => typeof item === 'object' && item !== null && '_wkltId' in item)
+ .map(item => item._wkltId);
+
+ expect(workletIds).toHaveLength(2);
+ expect(new Set(workletIds).size).toBe(2);
+
+ const firstView = getByText('first').parentNode;
+ const secondView = getByText('second').parentNode;
+ fireEvent.tap(firstView, {
+ key: 'first-key',
+ });
+ fireEvent.tap(secondView, {
+ key: 'second-key',
+ });
+
+ expect(globalThis.firstCb).toBeCalledTimes(1);
+ expect(globalThis.firstCb).toBeCalledWith('first-key');
+ expect(globalThis.secondCb).toBeCalledTimes(1);
+ expect(globalThis.secondCb).toBeCalledWith('second-key');
+ vi.resetAllMocks();
+ });
});
diff --git a/packages/react/transform/__test__/fixture.spec.js b/packages/react/transform/__test__/fixture.spec.js
index f6331fe757..ae9cf78d22 100644
--- a/packages/react/transform/__test__/fixture.spec.js
+++ b/packages/react/transform/__test__/fixture.spec.js
@@ -1453,6 +1453,29 @@ class X extends Component {
});
describe('worklet', () => {
+ const lepusWorkletOptions = {
+ pluginName: '',
+ filename: '',
+ sourcemap: false,
+ cssScope: false,
+ jsx: false,
+ directiveDCE: true,
+ defineDCE: {
+ define: {
+ __LEPUS__: 'true',
+ __JS__: 'false',
+ },
+ },
+ shake: false,
+ compat: true,
+ refresh: false,
+ worklet: {
+ target: 'LEPUS',
+ filename: '',
+ runtimePkg: '@lynx-js/react',
+ },
+ };
+
it('should error on unsupported runtime import attribute', async () => {
const result = await transformReactLynx(
`\
@@ -1570,6 +1593,21 @@ export function bar() {
);
});
+ it('should not inject runtime when no worklet exists', async () => {
+ const { code } = await transformReactLynx(
+ `\
+export function getCurrentDelta(event) {
+ return foo.bar.baz;
+}
+`,
+ lepusWorkletOptions,
+ );
+
+ expect(code).not.toContain('loadWorkletRuntime');
+ expect(code).not.toContain('registerWorkletInternal');
+ expect(code).not.toContain('_wkltId');
+ });
+
for (const target of ['LEPUS', 'JS', 'MIXED']) {
it('member expression', async () => {
const { code } = await transformReactLynx(
@@ -1626,6 +1664,8 @@ export function bar() {
});
"
`);
+ expect(code).toContain('loadWorkletRuntime');
+ expect(code).toContain('registerWorkletInternal("main-thread"');
} else if (target === 'JS') {
expect(code).toMatchInlineSnapshot(`
"export let getCurrentDelta = {
@@ -1640,6 +1680,8 @@ export function bar() {
};
"
`);
+ expect(code).not.toContain('loadWorkletRuntime');
+ expect(code).not.toContain('registerWorkletInternal');
} else if (target === 'MIXED') {
expect(code).toMatchInlineSnapshot(`
"import { loadWorkletRuntime as __loadWorkletRuntime } from "@lynx-js/react";
@@ -1663,6 +1705,8 @@ export function bar() {
});
"
`);
+ expect(code).toContain('loadWorkletRuntime');
+ expect(code).toContain('registerWorkletInternal("main-thread"');
}
});
}
@@ -1729,6 +1773,8 @@ export function foo(event) {
});
"
`);
+ expect((code.match(/registerWorkletInternal/g) ?? []).length).toBe(1);
+ expect((code.match(/const __workletRuntimeLoaded = loadWorkletRuntime/g) ?? []).length).toBe(1);
});
it('nested', async () => {
@@ -1795,6 +1841,8 @@ console.log(bar)
});
"
`);
+ expect((code.match(/registerWorkletInternal/g) ?? []).length).toBe(2);
+ expect((code.match(/const __workletRuntimeLoaded = loadWorkletRuntime/g) ?? []).length).toBe(1);
});
it('use multiple times', async () => {
@@ -1845,6 +1893,8 @@ function getCurrentDelta(event) {
});
"
`);
+ expect((code.match(/registerWorkletInternal/g) ?? []).length).toBe(1);
+ expect((code.match(/const __workletRuntimeLoaded = loadWorkletRuntime/g) ?? []).length).toBe(1);
});
it('should keep webpack runtime variables', async () => {
diff --git a/packages/react/worklet-runtime/__test__/workletRuntime.test.js b/packages/react/worklet-runtime/__test__/workletRuntime.test.js
index 9a789782a3..d61a1e7007 100644
--- a/packages/react/worklet-runtime/__test__/workletRuntime.test.js
+++ b/packages/react/worklet-runtime/__test__/workletRuntime.test.js
@@ -39,6 +39,22 @@ describe('Worklet', () => {
expect(fn).toBeCalled();
});
+ it('latest registration should win when the same worklet id is reused', () => {
+ initWorklet();
+
+ const first = vi.fn();
+ const second = vi.fn();
+ globalThis.registerWorklet('main-thread', '1', first);
+ globalThis.registerWorklet('main-thread', '1', second);
+
+ globalThis.runWorklet({
+ _wkltId: '1',
+ });
+
+ expect(first).not.toBeCalled();
+ expect(second).toBeCalled();
+ });
+
it('worklet should be called with arguments', async () => {
initWorklet();
diff --git a/packages/webpack/react-webpack-plugin/test/cases/worklet-runtime/chunk/a.jsx b/packages/webpack/react-webpack-plugin/test/cases/worklet-runtime/chunk/a.jsx
index deaf91df33..22a713add7 100644
--- a/packages/webpack/react-webpack-plugin/test/cases/worklet-runtime/chunk/a.jsx
+++ b/packages/webpack/react-webpack-plugin/test/cases/worklet-runtime/chunk/a.jsx
@@ -2,10 +2,16 @@ export function a2() {
const onTapMT = () => {
'main thread';
};
+ const onLongPressMT = () => {
+ 'main thread';
+ };
return (
-
+
hello world
diff --git a/packages/webpack/react-webpack-plugin/test/cases/worklet-runtime/chunk/index.js b/packages/webpack/react-webpack-plugin/test/cases/worklet-runtime/chunk/index.js
index f2ee0341b2..404d21df58 100644
--- a/packages/webpack/react-webpack-plugin/test/cases/worklet-runtime/chunk/index.js
+++ b/packages/webpack/react-webpack-plugin/test/cases/worklet-runtime/chunk/index.js
@@ -1,23 +1 @@
-///
-// @ts-check
-
-import fs from 'node:fs/promises';
-import path from 'node:path';
-
-import './a.jsx';
-
-it('should have worklet-runtime', async () => {
- const source = await fs.readFile(
- path.resolve(
- path.join(
- path.dirname(__filename),
- '.rspeedy',
- 'tasm.json',
- ),
- ),
- 'utf-8',
- );
- const json = JSON.parse(source);
- expect(json['lepusCode']['lepusChunk']['worklet-runtime'].length > 0)
- .toBe(true);
-});
+export { a2 } from './a.jsx';
diff --git a/packages/webpack/react-webpack-plugin/test/cases/worklet-runtime/chunk/test.config.cjs b/packages/webpack/react-webpack-plugin/test/cases/worklet-runtime/chunk/test.config.cjs
index a9b8c33fb3..f5028bb48a 100644
--- a/packages/webpack/react-webpack-plugin/test/cases/worklet-runtime/chunk/test.config.cjs
+++ b/packages/webpack/react-webpack-plugin/test/cases/worklet-runtime/chunk/test.config.cjs
@@ -1,7 +1,5 @@
/** @type {import("@lynx-js/test-tools").TConfigCaseConfig} */
module.exports = {
- bundlePath: [
- 'main__main-thread.js',
- 'main__background.js',
- ],
+ // This case only asserts encoded output artifacts in worklet-runtime.test.ts.
+ bundlePath: [],
};
diff --git a/packages/webpack/react-webpack-plugin/test/cases/worklet-runtime/not-using/index.js b/packages/webpack/react-webpack-plugin/test/cases/worklet-runtime/not-using/index.js
index adf8037b67..404d21df58 100644
--- a/packages/webpack/react-webpack-plugin/test/cases/worklet-runtime/not-using/index.js
+++ b/packages/webpack/react-webpack-plugin/test/cases/worklet-runtime/not-using/index.js
@@ -1,23 +1 @@
-///
-// @ts-check
-
-import fs from 'node:fs/promises';
-import path from 'node:path';
-
-import './a.jsx';
-
-it('should not have worklet-runtime', async () => {
- const source = await fs.readFile(
- path.resolve(
- path.join(
- path.dirname(__filename),
- '.rspeedy',
- 'tasm.json',
- ),
- ),
- 'utf-8',
- );
- const json = JSON.parse(source);
- expect(json['lepusCode']['lepusChunk']['worklet-runtime'])
- .toBe(undefined);
-});
+export { a2 } from './a.jsx';
diff --git a/packages/webpack/react-webpack-plugin/test/cases/worklet-runtime/not-using/test.config.cjs b/packages/webpack/react-webpack-plugin/test/cases/worklet-runtime/not-using/test.config.cjs
index a9b8c33fb3..f5028bb48a 100644
--- a/packages/webpack/react-webpack-plugin/test/cases/worklet-runtime/not-using/test.config.cjs
+++ b/packages/webpack/react-webpack-plugin/test/cases/worklet-runtime/not-using/test.config.cjs
@@ -1,7 +1,5 @@
/** @type {import("@lynx-js/test-tools").TConfigCaseConfig} */
module.exports = {
- bundlePath: [
- 'main__main-thread.js',
- 'main__background.js',
- ],
+ // This case only asserts encoded output artifacts in worklet-runtime.test.ts.
+ bundlePath: [],
};
diff --git a/packages/webpack/react-webpack-plugin/test/worklet-runtime.test.ts b/packages/webpack/react-webpack-plugin/test/worklet-runtime.test.ts
new file mode 100644
index 0000000000..4795a46baa
--- /dev/null
+++ b/packages/webpack/react-webpack-plugin/test/worklet-runtime.test.ts
@@ -0,0 +1,190 @@
+// 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 fs from 'node:fs/promises';
+import path from 'node:path';
+import { pathToFileURL } from 'node:url';
+
+import { rspack } from '@rspack/core';
+import type { RspackOptions, Stats } from '@rspack/core';
+import { describe, expect, it } from 'vitest';
+
+interface WorkletRuntimeCase {
+ caseName: string;
+ expectedChunkNames: string[];
+ expectedInitSignatureCount: number;
+ expectedRegisterIdCount: number;
+}
+
+interface BuildOutput {
+ lepusChunk: Record;
+ mainThreadSource: string;
+}
+
+const casesRoot = path.resolve(
+ __dirname,
+ 'cases',
+ 'worklet-runtime',
+);
+const distRoot = path.resolve(
+ __dirname,
+ 'dist',
+ 'worklet-runtime',
+);
+
+function parseLepusChunk(
+ source: string,
+ caseName: string,
+): Record {
+ const data: unknown = JSON.parse(source);
+ if (
+ typeof data !== 'object'
+ || data === null
+ || !('lepusCode' in data)
+ || typeof data.lepusCode !== 'object'
+ || data.lepusCode === null
+ || !('lepusChunk' in data.lepusCode)
+ || typeof data.lepusCode.lepusChunk !== 'object'
+ || data.lepusCode.lepusChunk === null
+ ) {
+ throw new Error(`Unexpected tasm shape for case ${caseName}`);
+ }
+
+ return data.lepusCode.lepusChunk as Record;
+}
+
+function countOccurrences(source: string, needle: string): number {
+ let count = 0;
+ let index = -1;
+
+ while ((index = source.indexOf(needle, index + 1)) !== -1) {
+ count += 1;
+ }
+
+ return count;
+}
+
+function extractRegisteredWorkletIds(source: string): string[] {
+ const matches = source.matchAll(
+ /registerWorkletInternal\(\\"main-thread\\",\s*\\"([^\\"]+)\\"/g,
+ );
+
+ return Array.from(matches, match => match[1]);
+}
+
+async function buildCase(caseName: string): Promise {
+ const caseDir = path.join(casesRoot, caseName);
+ const caseConfigPath = path.join(caseDir, 'rspack.config.js');
+ const outputPath = path.join(distRoot, caseName);
+
+ await fs.rm(outputPath, { recursive: true, force: true });
+
+ const configModule = await import(
+ pathToFileURL(caseConfigPath).href
+ ) as { default: RspackOptions };
+ const baseConfig = configModule.default;
+ const config: RspackOptions = {
+ ...baseConfig,
+ mode: 'development' as const,
+ output: {
+ ...(baseConfig.output ?? {}),
+ path: outputPath,
+ },
+ };
+
+ await new Promise((resolve, reject) => {
+ const compiler = rspack(config);
+ compiler.run((error, stats) => {
+ compiler.close(closeError => {
+ if (error) {
+ reject(error);
+ return;
+ }
+ if (closeError) {
+ reject(closeError);
+ return;
+ }
+ if (!stats) {
+ reject(new Error(`Missing stats for case ${caseName}`));
+ return;
+ }
+ if (stats.hasErrors()) {
+ reject(
+ new Error(
+ stats.toString({
+ all: false,
+ errors: true,
+ errorDetails: true,
+ }),
+ ),
+ );
+ return;
+ }
+ resolve(stats);
+ });
+ });
+ });
+
+ const tasmPath = path.join(outputPath, '.rspeedy', 'tasm.json');
+ const tasm = await fs.readFile(tasmPath, 'utf8');
+ const mainThreadPath = path.join(outputPath, 'main__main-thread.js');
+ const mainThreadSource = await fs.readFile(mainThreadPath, 'utf8');
+
+ return {
+ lepusChunk: parseLepusChunk(tasm, caseName),
+ mainThreadSource,
+ };
+}
+
+describe('worklet-runtime bundler guardrails', () => {
+ it.each([
+ {
+ caseName: 'chunk',
+ expectedChunkNames: ['worklet-runtime'],
+ expectedInitSignatureCount: 1,
+ expectedRegisterIdCount: 2,
+ },
+ {
+ caseName: 'not-using',
+ expectedChunkNames: [],
+ expectedInitSignatureCount: 0,
+ expectedRegisterIdCount: 0,
+ },
+ ])(
+ 'should emit the expected worklet chunks for $caseName',
+ async ({
+ caseName,
+ expectedChunkNames,
+ expectedInitSignatureCount,
+ expectedRegisterIdCount,
+ }) => {
+ const { lepusChunk, mainThreadSource } = await buildCase(caseName);
+ const workletRuntimeChunks = Object.keys(lepusChunk).filter(
+ name => name === 'worklet-runtime',
+ );
+ const registeredWorkletIds = extractRegisteredWorkletIds(
+ mainThreadSource,
+ );
+
+ expect(workletRuntimeChunks).toEqual(expectedChunkNames);
+
+ if (expectedChunkNames.length > 0) {
+ expect(lepusChunk['worklet-runtime'].length).toBeGreaterThan(0);
+ expect(
+ countOccurrences(
+ lepusChunk['worklet-runtime'],
+ 'globalThis.lynxWorkletImpl = {',
+ ),
+ ).toBe(expectedInitSignatureCount);
+ } else {
+ expect(lepusChunk['worklet-runtime']).toBeUndefined();
+ expect(expectedInitSignatureCount).toBe(0);
+ }
+
+ expect(registeredWorkletIds).toHaveLength(expectedRegisterIdCount);
+ expect(new Set(registeredWorkletIds).size).toBe(
+ expectedRegisterIdCount,
+ );
+ },
+ );
+});