@lynx-js/react-webpack-plugin
Version:
A webpack plugin for ReactLynx
349 lines • 16.7 kB
JavaScript
// 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 * as fs from 'node:fs';
import { createRequire } from 'node:module';
import invariant from 'tiny-invariant';
import { LynxTemplatePlugin } from '@lynx-js/template-webpack-plugin';
import { RuntimeGlobals } from '@lynx-js/webpack-runtime-globals';
import { LAYERS } from './layer.js';
import { ELEMENT_TEMPLATE_BUILD_INFO } from './loaders/main-thread.js';
import { createLynxProcessEvalResultRuntimeModule } from './LynxProcessEvalResultRuntimeModule.js';
const require = createRequire(import.meta.url);
export function collectElementTemplatesFromModule(module) {
const elementTemplates = [];
const templates = module.buildInfo?.[ELEMENT_TEMPLATE_BUILD_INFO];
if (Array.isArray(templates)) {
elementTemplates.push(...templates);
}
if (module.modules) {
for (const nestedModule of module.modules) {
elementTemplates.push(...collectElementTemplatesFromModule(nestedModule));
}
}
return elementTemplates;
}
function isPlainRecord(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function areCompiledTemplateValuesEqual(left, right) {
if (Object.is(left, right)) {
return true;
}
if (Array.isArray(left) || Array.isArray(right)) {
if (!Array.isArray(left)
|| !Array.isArray(right)
|| left.length !== right.length) {
return false;
}
return left.every((value, index) => areCompiledTemplateValuesEqual(value, right[index]));
}
if (isPlainRecord(left) || isPlainRecord(right)) {
if (!isPlainRecord(left) || !isPlainRecord(right)) {
return false;
}
const leftKeys = Object.keys(left).sort();
const rightKeys = Object.keys(right).sort();
if (leftKeys.length !== rightKeys.length) {
return false;
}
return leftKeys.every((key, index) => key === rightKeys[index]
&& areCompiledTemplateValuesEqual(left[key], right[key]));
}
return false;
}
export function mergeElementTemplate(elementTemplates, templateId, compiledTemplate) {
const existingTemplate = elementTemplates[templateId];
if (!existingTemplate) {
elementTemplates[templateId] = compiledTemplate;
return;
}
if (areCompiledTemplateValuesEqual(existingTemplate, compiledTemplate)) {
return;
}
throw new Error(`Element Template id collision for ${templateId}: same template id has different compiledTemplate content.`);
}
export function mergeElementTemplatesFromModule(elementTemplates, module) {
for (const { templateId, compiledTemplate } of collectElementTemplatesFromModule(module)) {
mergeElementTemplate(elementTemplates, templateId, compiledTemplate);
}
}
/**
* Collect element templates for a single encoded bundle, scoped to the modules
* that belong to it. Iterating every module in the compilation would pull other
* bundles' modules — including dynamic components — into the result, duplicating
* their templates (e.g. a lazy component's template leaking into the main
* bundle). An entrypoint's chunk group contains only its initial chunks, so
* async (dynamic component) modules stay out of the main bundle, and each lazy
* bundle keeps just its own templates.
*
* @internal
*/
export function collectElementTemplatesForEntries(entryNames, getChunkGroup, getChunkModules) {
const elementTemplates = {};
const visited = new Set();
for (const entryName of entryNames) {
const chunkGroup = getChunkGroup(entryName);
if (chunkGroup === undefined) {
continue;
}
for (const chunk of chunkGroup.chunks) {
for (const module of getChunkModules(chunk)) {
if (visited.has(module)) {
continue;
}
visited.add(module);
mergeElementTemplatesFromModule(elementTemplates, module);
}
}
}
return elementTemplates;
}
/**
* ReactWebpackPlugin allows using ReactLynx with webpack
*
* @example
* ```js
* // webpack.config.js
* import { ReactWebpackPlugin } from '@lynx-js/react-webpack-plugin'
* export default {
* plugins: [new ReactWebpackPlugin()],
* }
* ```
*
* @public
*/
class ReactWebpackPlugin {
/**
* The loaders for ReactLynx.
*
* @remarks
* Note that this loader will only transform JSX/TSX to valid JavaScript.
* For `.tsx` files, the type annotations would not be eliminated.
* You should use `babel-loader` or `swc-loader` to load TypeScript files.
*
* @example
* ```js
* // webpack.config.js
* import { ReactWebpackPlugin, LAYERS } from '@lynx-js/react-webpack-plugin'
* export default {
* module: {
* rules: [
* {
* test: /\.tsx?$/,
* layer: LAYERS.MAIN_THREAD,
* use: ['swc-loader', ReactWebpackPlugin.loaders.MAIN_THREAD]
* },
* {
* test: /\.tsx?$/,
* layer: LAYERS.BACKGROUND,
* use: ['swc-loader', ReactWebpackPlugin.loaders.BACKGROUND]
* },
* ],
* },
* plugins: [new ReactWebpackPlugin()],
* }
* ```
*
* @public
*/
static { this.loaders = {
BACKGROUND: require.resolve('../lib/loaders/background.js'),
MAIN_THREAD: require.resolve('../lib/loaders/main-thread.js'),
TESTING: require.resolve('../lib/loaders/testing.js'),
}; }
constructor(options) {
this.options = options;
}
/**
* `defaultOptions` is the default options that the {@link ReactWebpackPlugin} uses.
*
* @public
*/
static { this.defaultOptions = Object
.freeze({
disableCreateSelectorQueryIncompatibleWarning: false,
firstScreenSyncTiming: 'immediately',
globalPropsMode: 'reactive',
enableSSR: false,
mainThreadChunks: [],
extractStr: false,
experimental_isLazyBundle: false,
profile: undefined,
workletRuntimePath: '',
experimental_useElementTemplate: false,
experimental_transformBuiltinAttributeNames: false,
lazyBundleFetcher: 'QueryComponent',
}); }
/**
* The entry point of a webpack plugin.
* @param compiler - the webpack compiler
*/
apply(compiler) {
const options = Object.assign({}, ReactWebpackPlugin.defaultOptions, this.options);
const { BannerPlugin, DefinePlugin, EnvironmentPlugin } = compiler.webpack;
if (!options.experimental_isLazyBundle) {
new BannerPlugin({
// TODO: handle cases that do not have `'use strict'`
banner: `'use strict';var globDynamicComponentEntry=globDynamicComponentEntry||'__Card__';`,
raw: true,
test: options.mainThreadChunks,
}).apply(compiler);
}
new EnvironmentPlugin({
// Default values of null and undefined behave differently.
// Use undefined for variables that must be provided during bundling, or null if they are optional.
DEBUG: null,
}).apply(compiler);
const isDev = process.env['NODE_ENV'] === 'development'
|| compiler.options.mode === 'development';
new DefinePlugin({
__DEV__: isDev,
// Whether preact devtools is enabled. Enabled by default in development;
// in production it can be enabled by environment variable `REACT_DEVTOOL`
// (which also keeps `@lynx-js/preact-devtools` from being stripped). This
// gates the dev-only runtime hooks that devtools depends on
// (e.g. `injectLepusMethods`).
__REACT_DEVTOOL__: JSON.stringify(isDev || Boolean(process.env['REACT_DEVTOOL'])),
// We enable profile by default in development.
// It can also be disabled by environment variable `REACT_PROFILE=false`
__PROFILE__: JSON.stringify(process.env['REACT_PROFILE']
?? options.profile
?? isDev),
// User can enable ALog by environment variable `REACT_ALOG=true`
__ALOG__: JSON.stringify(Boolean(process.env['REACT_ALOG'])),
// User can enable ALog of element API calls by environment variable `REACT_ALOG_ELEMENT_API=true`
__ALOG_ELEMENT_API__: JSON.stringify(Boolean(process.env['REACT_ALOG_ELEMENT_API'])),
__EXTRACT_STR__: JSON.stringify(Boolean(options.extractStr)),
__FIRST_SCREEN_SYNC_TIMING__: JSON.stringify(options.firstScreenSyncTiming),
__GLOBAL_PROPS_MODE__: JSON.stringify(options.globalPropsMode),
__ENABLE_SSR__: JSON.stringify(options.enableSSR),
__DISABLE_CREATE_SELECTOR_QUERY_INCOMPATIBLE_WARNING__: JSON.stringify(options.disableCreateSelectorQueryIncompatibleWarning),
__USE_ELEMENT_TEMPLATE__: JSON.stringify(options.experimental_useElementTemplate),
__EXPERIMENTAL_TRANSFORM_BUILTIN_ATTRIBUTE_NAMES__: JSON.stringify(options.experimental_transformBuiltinAttributeNames),
__LAZY_BUNDLE_FETCHER__: JSON.stringify(options.lazyBundleFetcher),
}).apply(compiler);
compiler.hooks.thisCompilation.tap(this.constructor.name, compilation => {
const onceForChunkSet = new WeakSet();
compilation.hooks.runtimeRequirementInTree.for(compiler.webpack.RuntimeGlobals.ensureChunkHandlers).tap('ReactWebpackPlugin', (_, runtimeRequirements) => {
runtimeRequirements.add(RuntimeGlobals.lynxProcessEvalResultByHost);
});
compilation.hooks.runtimeRequirementInTree.for(RuntimeGlobals.lynxProcessEvalResultByHost).tap('ReactWebpackPlugin', (chunk) => {
if (onceForChunkSet.has(chunk)) {
return;
}
onceForChunkSet.add(chunk);
const isMainThreadChunk = Array.from(compilation.chunkGraph.getChunkModulesIterable(chunk)).some(module => module.layer === LAYERS.MAIN_THREAD);
if (!isMainThreadChunk) {
return;
}
const LynxProcessEvalResultRuntimeModule = createLynxProcessEvalResultRuntimeModule(compiler.webpack);
compilation.addRuntimeModule(chunk, new LynxProcessEvalResultRuntimeModule());
});
compilation.hooks.processAssets.tap({
name: this.constructor.name,
stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL,
}, () => {
for (const name of options.mainThreadChunks ?? []) {
this.#updateMainThreadInfo(compilation, name);
}
compilation.chunkGroups
// Async ChunkGroups
.filter(cg => !cg.isInitial())
// MainThread ChunkGroups
.filter(cg => cg.origins.every(origin => origin.module?.layer === LAYERS.MAIN_THREAD))
.forEach(cg => {
const files = cg.getFiles();
files
.filter(name => name.endsWith('.js'))
.forEach(name => this.#updateMainThreadInfo(compilation, name));
});
});
const hooks = LynxTemplatePlugin.getLynxTemplatePluginHooks(compilation);
const { RawSource, ConcatSource } = compiler.webpack.sources;
hooks.beforeEncode.tap(this.constructor.name, (args) => {
const lepusCode = args.encodeData.lepusCode;
if (lepusCode.root?.source.source().toString()?.includes('registerWorkletInternal')) {
lepusCode.chunks.push({
name: 'worklet-runtime',
source: new RawSource(fs.readFileSync(options.workletRuntimePath, 'utf8')),
info: {
['lynx:main-thread']: true,
},
});
}
return args;
});
if (compiler.options.plugins.some((p) => p instanceof LynxTemplatePlugin)) {
compilation.hooks.processAssets.tap({
name: this.constructor.name,
// This wrapper must be injected after size/minify optimizations have
// produced stable JS, but before devtool plugins finalize sourcemaps and
// later encode hooks consume the wrapped asset.
//
// - Too early (<= OPTIMIZE_SIZE): the wrapper is added before the
// minimizer runs. For lazy bundles, the minimizer can treat the wrapped
// content as removable and collapse the emitted asset down to empty code.
// - Too late (>= DEV_TOOLING): SourceMapDevToolPlugin emits `.map` assets
// and rewrites JS with `sourceMappingURL` in DEV_TOOLING. If we prepend
// wrapper lines after that point, the generated JS shifts but mappings do
// not.
//
// OPTIMIZE_SIZE + 1 is the safe window where both the emitted code and its
// sourcemap stay aligned.
stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE
+ 1,
}, () => {
const wrappedFiles = new Set();
compilation.chunkGroups.forEach(chunkGroup => {
const isDynamicImport = !chunkGroup.isInitial()
&& chunkGroup.origins.every(origin => origin.module?.layer === LAYERS.MAIN_THREAD);
chunkGroup.chunks.forEach(chunk => {
for (const file of chunk.files) {
if (!file.endsWith('.js')) {
continue;
}
const shouldInjectWrapper = isDynamicImport
|| (options.experimental_isLazyBundle
&& options.mainThreadChunks?.includes(file));
if (!shouldInjectWrapper) {
continue;
}
// A shared async chunk can belong to multiple chunk groups.
if (wrappedFiles.has(file)) {
continue;
}
const asset = compilation.getAsset(file);
if (!asset) {
continue;
}
wrappedFiles.add(file);
compilation.updateAsset(file, old => new ConcatSource(`(function (globDynamicComponentEntry) {\n`, ` const module = { exports: {} }\n`, ` const exports = module.exports;\n`, old, `\n ;return module.exports\n})`));
}
});
});
});
}
if (options.experimental_useElementTemplate) {
hooks.beforeEncode.tap(`${this.constructor.name}.ElementTemplate`, (args) => {
const { chunkGraph } = compilation;
const elementTemplates = collectElementTemplatesForEntries(args.chunkGroups.flatMap(cg => cg.name === null || cg.name === undefined ? [] : [cg.name]), (name) => compilation.namedChunkGroups.get(name), (chunk) => chunkGraph.getChunkModules(chunk));
args.encodeData.sourceContent.config['enableUnifyFixedBehavior'] =
true;
args.encodeData.elementTemplate = elementTemplates;
return args;
});
}
});
}
#updateMainThreadInfo(compilation, name) {
const asset = compilation.getAsset(name);
invariant(asset, `Should have main thread asset ${name}`);
compilation.updateAsset(asset.name, asset.source, {
...asset.info,
'lynx:main-thread': true,
});
}
}
export { ReactWebpackPlugin as ReactWebpackPlugin };
//# sourceMappingURL=ReactWebpackPlugin.js.map