From ff75a622e2f65cd7c8a69cc9dd4ae1f01d969d69 Mon Sep 17 00:00:00 2001 From: yradex <11014207+Yradex@users.noreply.github.com> Date: Thu, 5 Mar 2026 19:57:22 +0800 Subject: [PATCH 1/4] feat(react-webpack): route lepus chunks through standard pipeline for slardar sourcemaps --- .changeset/sweet-rings-kneel.md | 10 + packages/rspeedy/plugin-react/src/entry.ts | 52 ++++- .../rspeedy/plugin-react/test/config.test.ts | 34 ++++ .../src/ReactWebpackPlugin.ts | 65 +++++- .../src/lepusChunkPipeline.ts | 191 ++++++++++++++++++ .../cases/worklet-runtime/not-using/index.js | 7 + .../cases/worklet-runtime/standard-path/a.jsx | 13 ++ .../worklet-runtime/standard-path/index.js | 48 +++++ .../standard-path/rspack.config.js | 31 +++ .../standard-path/test.config.cjs | 7 + 10 files changed, 438 insertions(+), 20 deletions(-) create mode 100644 .changeset/sweet-rings-kneel.md create mode 100644 packages/webpack/react-webpack-plugin/src/lepusChunkPipeline.ts create mode 100644 packages/webpack/react-webpack-plugin/test/cases/worklet-runtime/standard-path/a.jsx create mode 100644 packages/webpack/react-webpack-plugin/test/cases/worklet-runtime/standard-path/index.js create mode 100644 packages/webpack/react-webpack-plugin/test/cases/worklet-runtime/standard-path/rspack.config.js create mode 100644 packages/webpack/react-webpack-plugin/test/cases/worklet-runtime/standard-path/test.config.cjs diff --git a/.changeset/sweet-rings-kneel.md b/.changeset/sweet-rings-kneel.md new file mode 100644 index 0000000000..323a9a7c41 --- /dev/null +++ b/.changeset/sweet-rings-kneel.md @@ -0,0 +1,10 @@ +--- +"@lynx-js/react-webpack-plugin": patch +"@lynx-js/react-rsbuild-plugin": patch +--- + +Route lepus runtime chunks (including `worklet-runtime`) through the standard chunk compilation pipeline so sourcemaps can be discovered by upload plugins, while keeping template injection name and runtime loading behavior unchanged. + +`@lynx-js/react-webpack-plugin` now compiles lepus chunks as normal entries, injects compiled output into template lepus chunks, and removes generated lepus chunk assets from final output after report-stage processing. + +`@lynx-js/react-rsbuild-plugin` updates runtime-wrapper exclusion to use lepus chunk names (instead of hardcoded single-chunk matching), ensuring main-thread/lepus executable chunks keep plain output. diff --git a/packages/rspeedy/plugin-react/src/entry.ts b/packages/rspeedy/plugin-react/src/entry.ts index 52151fa067..a7691d1da6 100644 --- a/packages/rspeedy/plugin-react/src/entry.ts +++ b/packages/rspeedy/plugin-react/src/entry.ts @@ -57,6 +57,15 @@ export function applyEntry( api.modifyBundlerChain(async (chain, { environment, isDev, isProd }) => { const mainThreadChunks: string[] = [] + const { resolve } = api.useExposed< + { resolve: (request: string) => Promise } + >(Symbol.for('@lynx-js/react/internal:resolve'))! + const workletRuntimePath = await resolve( + `@lynx-js/react/${isDev ? 'worklet-dev-runtime' : 'worklet-runtime'}`, + ) + const lepusChunkNames = getLepusChunkNames({ + workletRuntimePath, + }) const rsbuildConfig = api.getRsbuildConfig() const userConfig = api.getRsbuildConfig('original') @@ -235,8 +244,11 @@ export function applyEntry( }) }, targetSdkVersion, - // Inject runtime wrapper for all `.js` but not `main-thread.js` and `main-thread.[hash].js`. - test: /^(?!.*main-thread(?:\.[A-Fa-f0-9]*)?\.js$).*\.js$/, + // Inject runtime wrapper for all `.js` except chunks that should keep + // plain executable output (main-thread + all lepus chunks). + test: createRuntimeWrapperTestPattern( + lepusChunkNames, + ), experimental_isLazyBundle, }]) .end() @@ -261,10 +273,6 @@ export function applyEntry( extractStr = false } - const { resolve } = api.useExposed< - { resolve: (request: string) => Promise } - >(Symbol.for('@lynx-js/react/internal:resolve'))! - chain .plugin(PLUGIN_NAME_REACT) .after(PLUGIN_NAME_TEMPLATE) @@ -277,9 +285,7 @@ export function applyEntry( extractStr, experimental_isLazyBundle, profile: getDefaultProfile(), - workletRuntimePath: await resolve( - `@lynx-js/react/${isDev ? 'worklet-dev-runtime' : 'worklet-runtime'}`, - ), + workletRuntimePath, }]) function getDefaultProfile(): boolean | undefined { @@ -387,3 +393,31 @@ function getHash( return EMPTY_HASH } } + +function createRuntimeWrapperTestPattern(lepusChunkNames: string[]): RegExp { + const excluded = [ + 'main-thread', + ...lepusChunkNames, + ] + const escaped = excluded.map((name) => escapeRegExp(name)) + .join('|') + return new RegExp( + `^(?!.*(?:${escaped})(?:\\.[^/.]+)?\\.js$).*\\.js$`, + ) +} + +function escapeRegExp(input: string): string { + return input.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function getLepusChunkNames(options: { + workletRuntimePath: string +}): string[] { + const chunkNames: string[] = [] + + if (options.workletRuntimePath) { + chunkNames.push('worklet-runtime') + } + + return chunkNames +} diff --git a/packages/rspeedy/plugin-react/test/config.test.ts b/packages/rspeedy/plugin-react/test/config.test.ts index 3c0e9559ff..922b52fb53 100644 --- a/packages/rspeedy/plugin-react/test/config.test.ts +++ b/packages/rspeedy/plugin-react/test/config.test.ts @@ -2417,6 +2417,40 @@ describe('Config', () => { ) }) + test('runtime wrapper excludes lepus chunks and main-thread assets', async () => { + const { pluginReactLynx } = await import('../src/pluginReactLynx.js') + const rspeedy = await createRspeedy({ + rspeedyConfig: { + mode: 'production', + plugins: [ + pluginReactLynx(), + pluginStubRspeedyAPI(), + ], + }, + }) + + const [config] = await rspeedy.initConfigs() + const runtimeWrapperPlugin = config?.plugins?.find( + p => p?.constructor.name === 'RuntimeWrapperWebpackPlugin', + ) + + if (!runtimeWrapperPlugin) { + expect.fail('Should have RuntimeWrapperWebpackPlugin instance') + } + + const runtimeWrapperPluginWithOptions = runtimeWrapperPlugin as { + options: { test: RegExp } + } + const testRule = runtimeWrapperPluginWithOptions.options.test + expect(testRule).toBeInstanceOf(RegExp) + + expect(testRule.test('.rspeedy/main/main-thread.js')).toBe(false) + // `worklet-runtime` is currently the only lepus chunk. + expect(testRule.test('static/js/worklet-runtime.js')).toBe(false) + expect(testRule.test('static/js/worklet-runtime.abcd1234.js')).toBe(false) + expect(testRule.test('.rspeedy/main/background.js')).toBe(true) + }) + describe('environment', () => { test('lynx environment', async () => { const { pluginReactLynx } = await import('../src/pluginReactLynx.js') diff --git a/packages/webpack/react-webpack-plugin/src/ReactWebpackPlugin.ts b/packages/webpack/react-webpack-plugin/src/ReactWebpackPlugin.ts index b1a37f6d9c..e229f2fd31 100644 --- a/packages/webpack/react-webpack-plugin/src/ReactWebpackPlugin.ts +++ b/packages/webpack/react-webpack-plugin/src/ReactWebpackPlugin.ts @@ -2,7 +2,6 @@ // 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 * as fs from 'node:fs'; import { createRequire } from 'node:module'; import type { Chunk, Compilation, Compiler } from '@rspack/core'; @@ -13,6 +12,14 @@ import { LynxTemplatePlugin } from '@lynx-js/template-webpack-plugin'; import { RuntimeGlobals } from '@lynx-js/webpack-runtime-globals'; import { LAYERS } from './layer.js'; +import { + createIsolatedLepusChunkSource, + createLepusChunkPipeline, + excludeLepusChunksFromTemplate, + getLepusChunkGeneratedAssetNames, + getLepusChunkPrimaryJsAsset, + injectLepusChunkEntries, +} from './lepusChunkPipeline.js'; import { createLynxProcessEvalResultRuntimeModule } from './LynxProcessEvalResultRuntimeModule.js'; const require = createRequire(import.meta.url); @@ -156,6 +163,10 @@ class ReactWebpackPlugin { this.options, ); const { BannerPlugin, DefinePlugin, EnvironmentPlugin } = compiler.webpack; + const lepusChunkPipeline = createLepusChunkPipeline(options); + + injectLepusChunkEntries(compiler, lepusChunkPipeline); + excludeLepusChunksFromTemplate(compiler, lepusChunkPipeline); if (!options.experimental_isLazyBundle) { new BannerPlugin({ @@ -264,17 +275,25 @@ class ReactWebpackPlugin { this.constructor.name, (args) => { const lepusCode = args.encodeData.lepusCode; - if ( - lepusCode.root?.source.source().toString()?.includes( - 'registerWorkletInternal', - ) - ) { + const lepusRootSource = lepusCode.root?.source.source().toString(); + for (const chunk of lepusChunkPipeline) { + if (!chunk.shouldInject(lepusRootSource)) { + continue; + } + + const chunkAsset = getLepusChunkPrimaryJsAsset( + compilation, + chunk.chunkName, + ); + + const compiledChunkSource = chunkAsset.source.source().toString(); + const injectedChunkSource = createIsolatedLepusChunkSource( + compiledChunkSource, + ); + lepusCode.chunks.push({ - name: 'worklet-runtime', - source: new RawSource(fs.readFileSync( - options.workletRuntimePath, - 'utf8', - )), + name: chunk.chunkName, + source: new RawSource(injectedChunkSource), info: { ['lynx:main-thread']: true, }, @@ -325,6 +344,30 @@ class ReactWebpackPlugin { ?.replaceAll(`-react__background`, '') ?.replaceAll(`-react__main-thread`, ''), ); + + compilation.hooks.processAssets.tap( + { + name: `${this.constructor.name}:lepus-chunk-cleanup`, + // Run after normal report-stage consumers (e.g. sourcemap upload) + // so lepus chunk files can participate in toolchain processing + // but still not leak into final output assets. + stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_REPORT + + 1000, + }, + () => { + for (const chunk of lepusChunkPipeline) { + const assetNames = getLepusChunkGeneratedAssetNames( + compilation, + chunk.chunkName, + ); + for (const assetName of assetNames) { + if (compilation.getAsset(assetName)) { + compilation.deleteAsset(assetName); + } + } + } + }, + ); }); } diff --git a/packages/webpack/react-webpack-plugin/src/lepusChunkPipeline.ts b/packages/webpack/react-webpack-plugin/src/lepusChunkPipeline.ts new file mode 100644 index 0000000000..5eca4892a3 --- /dev/null +++ b/packages/webpack/react-webpack-plugin/src/lepusChunkPipeline.ts @@ -0,0 +1,191 @@ +// 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 type { Compilation, Compiler } from '@rspack/core'; +import invariant from 'tiny-invariant'; + +import { LynxTemplatePlugin } from '@lynx-js/template-webpack-plugin'; + +const WORKLET_RUNTIME_CHUNK_NAME: string = 'worklet-runtime'; + +interface LepusChunkPipelineItem { + chunkName: string; + sourcePath: string; + shouldInject: (lepusRootSource: string | undefined) => boolean; +} + +interface LepusChunkPipelineOptions { + workletRuntimePath: string; +} + +interface LepusChunkCompiledAsset { + source: { + source: () => { toString: () => string }; + }; +} + +function createLepusChunkPipeline( + options: LepusChunkPipelineOptions, +): LepusChunkPipelineItem[] { + const chunks: LepusChunkPipelineItem[] = []; + + if (options.workletRuntimePath) { + chunks.push({ + chunkName: WORKLET_RUNTIME_CHUNK_NAME, + sourcePath: options.workletRuntimePath, + shouldInject: (lepusRootSource) => + lepusRootSource?.includes('registerWorkletInternal') ?? false, + }); + } + + return chunks; +} + +function injectLepusChunkEntries( + compiler: Compiler, + lepusChunkPipeline: LepusChunkPipelineItem[], +): void { + for (const chunk of lepusChunkPipeline) { + if (hasEntry(compiler, chunk.chunkName)) { + continue; + } + + // Build lepus runtime chunks through the standard entry pipeline so + // source maps can be discovered from compilation assets/chunks. + new compiler.webpack.EntryPlugin( + compiler.context, + chunk.sourcePath, + { + name: chunk.chunkName, + }, + ).apply(compiler); + } +} + +function excludeLepusChunksFromTemplate( + compiler: Compiler, + lepusChunkPipeline: LepusChunkPipelineItem[], +): void { + const chunkNames = new Set(lepusChunkPipeline.map(chunk => chunk.chunkName)); + if (chunkNames.size === 0) { + return; + } + + for (const plugin of compiler.options.plugins ?? []) { + if (!(plugin instanceof LynxTemplatePlugin)) { + continue; + } + + const templatePlugin = plugin as unknown as { + options?: { + excludeChunks?: string[]; + }; + }; + templatePlugin.options ??= {}; + templatePlugin.options.excludeChunks ??= []; + + for (const chunkName of chunkNames) { + if (templatePlugin.options.excludeChunks.includes(chunkName)) { + continue; + } + templatePlugin.options.excludeChunks.push(chunkName); + } + } +} + +function getLepusChunkPrimaryJsAsset( + compilation: Compilation, + chunkName: string, +): LepusChunkCompiledAsset { + const jsAssetNames = getLepusChunkJsAssetNames(compilation, chunkName); + + invariant( + jsAssetNames.length === 1, + `[ReactWebpackPlugin] Lepus chunk "${chunkName}" must emit exactly one JS asset, but got ${jsAssetNames.length}: ${ + jsAssetNames.length > 0 ? jsAssetNames.join(', ') : '(none)' + }. Please disable split/runtime extraction for this chunk.`, + ); + + const jsAssetName = jsAssetNames[0]!; + const asset = compilation.getAsset(jsAssetName); + + invariant( + asset, + `[ReactWebpackPlugin] Missing compiled asset "${jsAssetName}" for chunk "${chunkName}".`, + ); + + invariant( + hasSourceAccessor(asset), + `[ReactWebpackPlugin] Asset "${jsAssetName}" of chunk "${chunkName}" has no valid source accessor.`, + ); + + return asset; +} + +function getLepusChunkGeneratedAssetNames( + compilation: Compilation, + chunkName: string, +): string[] { + const jsAssetNames = getLepusChunkJsAssetNames(compilation, chunkName); + const assetNames = new Set(jsAssetNames); + for (const jsAssetName of jsAssetNames) { + const sourceMapName = `${jsAssetName}.map`; + if (compilation.getAsset(sourceMapName)) { + assetNames.add(sourceMapName); + } + } + return [...assetNames]; +} + +function createIsolatedLepusChunkSource(source: string): string { + // Lepus chunks run in the main-thread JS realm. + // Keep compiled entry bootstrap out of global scope to avoid clobbering + // root chunk runtime symbols (`__webpack_require__`, `__webpack_modules__`). + return `(function(){${source}\n})();`; +} + +function getLepusChunkJsAssetNames( + compilation: Compilation, + chunkName: string, +): string[] { + const chunk = compilation.namedChunks.get(chunkName); + if (!chunk) { + return []; + } + + const files = [...chunk.files]; + return files.filter((name: string) => + name.endsWith('.js') && !name.endsWith('.hot-update.js') + ); +} + +function hasEntry(compiler: Compiler, name: string): boolean { + const { entry } = compiler.options; + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + return false; + } + return name in entry; +} + +function hasSourceAccessor(value: unknown): value is LepusChunkCompiledAsset { + if (!value || typeof value !== 'object') { + return false; + } + const source = (value as { source?: unknown }).source; + if (!source || typeof source !== 'object') { + return false; + } + const sourceGetter = (source as { source?: unknown }).source; + return typeof sourceGetter === 'function'; +} + +export { + createIsolatedLepusChunkSource, + createLepusChunkPipeline, + excludeLepusChunksFromTemplate, + getLepusChunkGeneratedAssetNames, + getLepusChunkPrimaryJsAsset, + injectLepusChunkEntries, +}; +export type { LepusChunkPipelineItem }; 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..9bf792c556 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 @@ -2,6 +2,7 @@ // @ts-check import fs from 'node:fs/promises'; +import { existsSync } from 'node:fs'; import path from 'node:path'; import './a.jsx'; @@ -21,3 +22,9 @@ it('should not have worklet-runtime', async () => { expect(json['lepusCode']['lepusChunk']['worklet-runtime']) .toBe(undefined); }); + +it('should not keep compiled worklet-runtime assets when injection is not needed', () => { + const root = path.dirname(__filename); + expect(existsSync(path.join(root, 'worklet-runtime.js'))).toBe(false); + expect(existsSync(path.join(root, 'worklet-runtime.js.map'))).toBe(false); +}); diff --git a/packages/webpack/react-webpack-plugin/test/cases/worklet-runtime/standard-path/a.jsx b/packages/webpack/react-webpack-plugin/test/cases/worklet-runtime/standard-path/a.jsx new file mode 100644 index 0000000000..deaf91df33 --- /dev/null +++ b/packages/webpack/react-webpack-plugin/test/cases/worklet-runtime/standard-path/a.jsx @@ -0,0 +1,13 @@ +export function a2() { + const onTapMT = () => { + 'main thread'; + }; + + return ( + + + hello world + + + ); +} diff --git a/packages/webpack/react-webpack-plugin/test/cases/worklet-runtime/standard-path/index.js b/packages/webpack/react-webpack-plugin/test/cases/worklet-runtime/standard-path/index.js new file mode 100644 index 0000000000..70ec63c6c0 --- /dev/null +++ b/packages/webpack/react-webpack-plugin/test/cases/worklet-runtime/standard-path/index.js @@ -0,0 +1,48 @@ +/// +// @ts-check + +import fs from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; + +import './a.jsx'; + +it('should keep worklet-runtime injection name unchanged', 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); +}); + +it('should not keep compiled worklet-runtime assets in final output when injection is needed', () => { + const root = path.dirname(__filename); + expect(existsSync(path.join(root, 'worklet-runtime.js'))).toBe(false); + expect(existsSync(path.join(root, 'worklet-runtime.js.map'))).toBe(false); +}); + +it('should inject worklet-runtime from compiled asset content', async () => { + const root = path.dirname(__filename); + const templateSource = await fs.readFile( + path.join(root, '.rspeedy', 'tasm.json'), + 'utf-8', + ); + + const templateJson = JSON.parse(templateSource); + const injected = templateJson['lepusCode']['lepusChunk']['worklet-runtime']; + expect(injected.includes('sourceMappingURL=worklet-runtime.js.map')).toBe( + true, + ); + expect( + injected.startsWith('(function(){') + || injected.includes('function __webpack_require__('), + ).toBe(true); +}); diff --git a/packages/webpack/react-webpack-plugin/test/cases/worklet-runtime/standard-path/rspack.config.js b/packages/webpack/react-webpack-plugin/test/cases/worklet-runtime/standard-path/rspack.config.js new file mode 100644 index 0000000000..07b489a806 --- /dev/null +++ b/packages/webpack/react-webpack-plugin/test/cases/worklet-runtime/standard-path/rspack.config.js @@ -0,0 +1,31 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { createConfig } from '../../../create-react-config.js'; +import { + LynxEncodePlugin, + LynxTemplatePlugin, +} from '@lynx-js/template-webpack-plugin'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const defaultConfig = createConfig({}, { + mainThreadChunks: ['main__main-thread.js'], +}, {}); + +/** @type {import('@rspack/core').Configuration} */ +export default { + context: __dirname, + mode: 'production', + devtool: 'source-map', + ...defaultConfig, + plugins: [ + ...defaultConfig.plugins, + new LynxEncodePlugin(), + new LynxTemplatePlugin({ + chunks: ['main__main-thread', 'main__background'], + filename: 'main/template.json', + intermediate: '.rspeedy', + }), + ], +}; diff --git a/packages/webpack/react-webpack-plugin/test/cases/worklet-runtime/standard-path/test.config.cjs b/packages/webpack/react-webpack-plugin/test/cases/worklet-runtime/standard-path/test.config.cjs new file mode 100644 index 0000000000..a9b8c33fb3 --- /dev/null +++ b/packages/webpack/react-webpack-plugin/test/cases/worklet-runtime/standard-path/test.config.cjs @@ -0,0 +1,7 @@ +/** @type {import("@lynx-js/test-tools").TConfigCaseConfig} */ +module.exports = { + bundlePath: [ + 'main__main-thread.js', + 'main__background.js', + ], +}; From b5249a58fa361e9b02a5a9f392a3956682494c09 Mon Sep 17 00:00:00 2001 From: yradex <11014207+Yradex@users.noreply.github.com> Date: Thu, 5 Mar 2026 21:28:28 +0800 Subject: [PATCH 2/4] feat(react-webpack-plugin): lazily compile worklet runtime via lepus chunk pipeline --- .../src/lepusChunkPipeline.ts | 96 ++++++++++++++++--- .../src/loaders/background.ts | 6 ++ .../src/loaders/main-thread.ts | 6 ++ .../src/workletMetadata.ts | 38 ++++++++ 4 files changed, 132 insertions(+), 14 deletions(-) create mode 100644 packages/webpack/react-webpack-plugin/src/workletMetadata.ts diff --git a/packages/webpack/react-webpack-plugin/src/lepusChunkPipeline.ts b/packages/webpack/react-webpack-plugin/src/lepusChunkPipeline.ts index 5eca4892a3..c30e33809a 100644 --- a/packages/webpack/react-webpack-plugin/src/lepusChunkPipeline.ts +++ b/packages/webpack/react-webpack-plugin/src/lepusChunkPipeline.ts @@ -7,11 +7,14 @@ import invariant from 'tiny-invariant'; import { LynxTemplatePlugin } from '@lynx-js/template-webpack-plugin'; +import { moduleHasWorkletUsage } from './workletMetadata.js'; + const WORKLET_RUNTIME_CHUNK_NAME: string = 'worklet-runtime'; interface LepusChunkPipelineItem { chunkName: string; sourcePath: string; + shouldCompile: (module: unknown) => boolean; shouldInject: (lepusRootSource: string | undefined) => boolean; } @@ -34,6 +37,7 @@ function createLepusChunkPipeline( chunks.push({ chunkName: WORKLET_RUNTIME_CHUNK_NAME, sourcePath: options.workletRuntimePath, + shouldCompile: moduleHasWorkletUsage, shouldInject: (lepusRootSource) => lepusRootSource?.includes('registerWorkletInternal') ?? false, }); @@ -46,21 +50,85 @@ function injectLepusChunkEntries( compiler: Compiler, lepusChunkPipeline: LepusChunkPipelineItem[], ): void { - for (const chunk of lepusChunkPipeline) { - if (hasEntry(compiler, chunk.chunkName)) { - continue; - } - - // Build lepus runtime chunks through the standard entry pipeline so - // source maps can be discovered from compilation assets/chunks. - new compiler.webpack.EntryPlugin( - compiler.context, - chunk.sourcePath, - { - name: chunk.chunkName, - }, - ).apply(compiler); + if (lepusChunkPipeline.length === 0) { + return; } + + compiler.hooks.make.tapAsync( + 'ReactWebpackPlugin:LepusChunkEntry', + (compilation, callback) => { + const requestedChunkNames = new Set(); + const queuedChunkByName = new Map(); + + compilation.hooks.succeedModule.tap( + 'ReactWebpackPlugin:LepusChunkEntry', + (module) => { + for (const chunk of lepusChunkPipeline) { + if (!chunk.shouldCompile(module)) { + continue; + } + requestedChunkNames.add(chunk.chunkName); + queuedChunkByName.set(chunk.chunkName, chunk); + } + }, + ); + + compilation.hooks.finishModules.tapAsync( + 'ReactWebpackPlugin:LepusChunkEntry', + (_modules, finishCallback) => { + const queuedChunks = [...requestedChunkNames] + .filter(name => !hasEntry(compiler, name)) + .map(name => queuedChunkByName.get(name)) + .filter((chunk): chunk is LepusChunkPipelineItem => Boolean(chunk)); + + if (queuedChunks.length === 0) { + finishCallback(); + return; + } + + let pending = queuedChunks.length; + let settled = false; + const done = (error?: Error) => { + if (settled) { + return; + } + if (error) { + settled = true; + finishCallback(error); + return; + } + pending -= 1; + if (pending === 0) { + settled = true; + finishCallback(); + } + }; + + for (const chunk of queuedChunks) { + const dependency = compiler.webpack.EntryPlugin.createDependency( + chunk.sourcePath, + ); + // Build lepus runtime chunks through the standard entry pipeline so + // source maps can be discovered from compilation assets/chunks. + compilation.addEntry( + compiler.context, + dependency, + { name: chunk.chunkName }, + (error) => { + if (error) { + done(error); + return; + } + done(); + }, + ); + } + }, + ); + + callback(); + }, + ); } function excludeLepusChunksFromTemplate( diff --git a/packages/webpack/react-webpack-plugin/src/loaders/background.ts b/packages/webpack/react-webpack-plugin/src/loaders/background.ts index f70ac61dad..5eb437edd3 100644 --- a/packages/webpack/react-webpack-plugin/src/loaders/background.ts +++ b/packages/webpack/react-webpack-plugin/src/loaders/background.ts @@ -7,6 +7,10 @@ import type { LoaderDefinitionFunction } from '@rspack/core'; import { getBackgroundTransformOptions } from './options.js'; import type { ReactLoaderOptions } from './options.js'; +import { + detectWorkletUsage, + setModuleWorkletUsage, +} from '../workletMetadata.js'; const backgroundLoader: LoaderDefinitionFunction = function( content, @@ -30,6 +34,8 @@ const backgroundLoader: LoaderDefinitionFunction = function( content, getBackgroundTransformOptions.call(this, swcInputSourceMap), ); + const hasWorklet = detectWorkletUsage(result.code); + setModuleWorkletUsage(this, hasWorklet); if (result.errors.length > 0) { for (const error of result.errors) { diff --git a/packages/webpack/react-webpack-plugin/src/loaders/main-thread.ts b/packages/webpack/react-webpack-plugin/src/loaders/main-thread.ts index aa8404f6d1..5f2f8587b7 100644 --- a/packages/webpack/react-webpack-plugin/src/loaders/main-thread.ts +++ b/packages/webpack/react-webpack-plugin/src/loaders/main-thread.ts @@ -7,6 +7,10 @@ import type { LoaderDefinitionFunction } from '@rspack/core'; import { getMainThreadTransformOptions } from './options.js'; import type { ReactLoaderOptions } from './options.js'; +import { + detectWorkletUsage, + setModuleWorkletUsage, +} from '../workletMetadata.js'; const mainThreadLoader: LoaderDefinitionFunction = function( content, @@ -30,6 +34,8 @@ const mainThreadLoader: LoaderDefinitionFunction = function( content, getMainThreadTransformOptions.call(this, swcInputSourceMap), ); + const hasWorklet = detectWorkletUsage(result.code); + setModuleWorkletUsage(this, hasWorklet); if (result.errors.length > 0) { for (const error of result.errors) { diff --git a/packages/webpack/react-webpack-plugin/src/workletMetadata.ts b/packages/webpack/react-webpack-plugin/src/workletMetadata.ts new file mode 100644 index 0000000000..608aa2b80b --- /dev/null +++ b/packages/webpack/react-webpack-plugin/src/workletMetadata.ts @@ -0,0 +1,38 @@ +// 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. + +const WORKLET_USAGE_BUILD_INFO_KEY = 'lynxHasWorklet'; +const WORKLET_REGISTER_INTERNAL_RE = /\bregisterWorkletInternal\s*\(/; + +interface ModuleWithBuildInfo { + buildInfo?: Record; +} + +interface LoaderContextWithModule { + _module?: ModuleWithBuildInfo; +} + +function detectWorkletUsage(transformedCode: string): boolean { + return WORKLET_REGISTER_INTERNAL_RE.test(transformedCode); +} + +function setModuleWorkletUsage( + loaderContext: unknown, + hasWorklet: boolean, +): void { + const module = (loaderContext as LoaderContextWithModule)._module; + if (!module) { + return; + } + + module.buildInfo ??= {}; + module.buildInfo[WORKLET_USAGE_BUILD_INFO_KEY] = hasWorklet; +} + +function moduleHasWorkletUsage(module: unknown): boolean { + const buildInfo = (module as ModuleWithBuildInfo)?.buildInfo; + return buildInfo?.[WORKLET_USAGE_BUILD_INFO_KEY] === true; +} + +export { detectWorkletUsage, moduleHasWorkletUsage, setModuleWorkletUsage }; From a40215b394547fd3dfd5d2e4d661ff743899e8f3 Mon Sep 17 00:00:00 2001 From: yradex <11014207+Yradex@users.noreply.github.com> Date: Fri, 6 Mar 2026 12:08:53 +0800 Subject: [PATCH 3/4] fix(react-webpack-plugin): handle cached lepus runtime modules --- .../src/lepusChunkPipeline.ts | 27 +++++-------------- .../worklet-runtime/standard-path/index.js | 5 +--- 2 files changed, 7 insertions(+), 25 deletions(-) diff --git a/packages/webpack/react-webpack-plugin/src/lepusChunkPipeline.ts b/packages/webpack/react-webpack-plugin/src/lepusChunkPipeline.ts index c30e33809a..d54a14320d 100644 --- a/packages/webpack/react-webpack-plugin/src/lepusChunkPipeline.ts +++ b/packages/webpack/react-webpack-plugin/src/lepusChunkPipeline.ts @@ -57,29 +57,14 @@ function injectLepusChunkEntries( compiler.hooks.make.tapAsync( 'ReactWebpackPlugin:LepusChunkEntry', (compilation, callback) => { - const requestedChunkNames = new Set(); - const queuedChunkByName = new Map(); - - compilation.hooks.succeedModule.tap( - 'ReactWebpackPlugin:LepusChunkEntry', - (module) => { - for (const chunk of lepusChunkPipeline) { - if (!chunk.shouldCompile(module)) { - continue; - } - requestedChunkNames.add(chunk.chunkName); - queuedChunkByName.set(chunk.chunkName, chunk); - } - }, - ); - compilation.hooks.finishModules.tapAsync( 'ReactWebpackPlugin:LepusChunkEntry', - (_modules, finishCallback) => { - const queuedChunks = [...requestedChunkNames] - .filter(name => !hasEntry(compiler, name)) - .map(name => queuedChunkByName.get(name)) - .filter((chunk): chunk is LepusChunkPipelineItem => Boolean(chunk)); + (modules, finishCallback) => { + const allModules = [...modules]; + const queuedChunks = lepusChunkPipeline.filter((chunk) => + !hasEntry(compiler, chunk.chunkName) + && allModules.some(module => chunk.shouldCompile(module)) + ); if (queuedChunks.length === 0) { finishCallback(); diff --git a/packages/webpack/react-webpack-plugin/test/cases/worklet-runtime/standard-path/index.js b/packages/webpack/react-webpack-plugin/test/cases/worklet-runtime/standard-path/index.js index 70ec63c6c0..c695d1bba9 100644 --- a/packages/webpack/react-webpack-plugin/test/cases/worklet-runtime/standard-path/index.js +++ b/packages/webpack/react-webpack-plugin/test/cases/worklet-runtime/standard-path/index.js @@ -41,8 +41,5 @@ it('should inject worklet-runtime from compiled asset content', async () => { expect(injected.includes('sourceMappingURL=worklet-runtime.js.map')).toBe( true, ); - expect( - injected.startsWith('(function(){') - || injected.includes('function __webpack_require__('), - ).toBe(true); + expect(injected.startsWith('(function(){')).toBe(true); }); From c46671870d5d00278a234fbedbedec8b81dab003 Mon Sep 17 00:00:00 2001 From: yradex <11014207+Yradex@users.noreply.github.com> Date: Mon, 9 Mar 2026 11:55:05 +0800 Subject: [PATCH 4/4] fix: changeset --- .changeset/green-tips-bake.md | 5 +++++ .changeset/silent-cars-trade.md | 7 +++++++ .changeset/sweet-rings-kneel.md | 10 ---------- 3 files changed, 12 insertions(+), 10 deletions(-) create mode 100644 .changeset/green-tips-bake.md create mode 100644 .changeset/silent-cars-trade.md delete mode 100644 .changeset/sweet-rings-kneel.md diff --git a/.changeset/green-tips-bake.md b/.changeset/green-tips-bake.md new file mode 100644 index 0000000000..700ef63f57 --- /dev/null +++ b/.changeset/green-tips-bake.md @@ -0,0 +1,5 @@ +--- +"@lynx-js/react-rsbuild-plugin": patch +--- + +Update runtime-wrapper exclusion to use configured lepus chunk names instead of hardcoded single-chunk matching, so executable lepus chunks keep plain output formatting. diff --git a/.changeset/silent-cars-trade.md b/.changeset/silent-cars-trade.md new file mode 100644 index 0000000000..881b305ae2 --- /dev/null +++ b/.changeset/silent-cars-trade.md @@ -0,0 +1,7 @@ +--- +"@lynx-js/react-webpack-plugin": patch +--- + +Route lepus runtime chunks, including `worklet-runtime`, through the standard chunk compilation pipeline so sourcemaps can be discovered by upload plugins while keeping template injection names and runtime loading behavior unchanged. + +`@lynx-js/react-webpack-plugin` now compiles lepus chunks as normal entries, injects the compiled output into template lepus chunks, and removes generated lepus chunk assets from final output after report-stage processing. diff --git a/.changeset/sweet-rings-kneel.md b/.changeset/sweet-rings-kneel.md deleted file mode 100644 index 323a9a7c41..0000000000 --- a/.changeset/sweet-rings-kneel.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"@lynx-js/react-webpack-plugin": patch -"@lynx-js/react-rsbuild-plugin": patch ---- - -Route lepus runtime chunks (including `worklet-runtime`) through the standard chunk compilation pipeline so sourcemaps can be discovered by upload plugins, while keeping template injection name and runtime loading behavior unchanged. - -`@lynx-js/react-webpack-plugin` now compiles lepus chunks as normal entries, injects compiled output into template lepus chunks, and removes generated lepus chunk assets from final output after report-stage processing. - -`@lynx-js/react-rsbuild-plugin` updates runtime-wrapper exclusion to use lepus chunk names (instead of hardcoded single-chunk matching), ensuring main-thread/lepus executable chunks keep plain output.