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, + ); + }, + ); +});