368 lines
14 KiB
JavaScript
368 lines
14 KiB
JavaScript
const { validate: validateOptions } = require('schema-utils');
|
|
const { getRefreshGlobalScope, getWebpackVersion } = require('./globals');
|
|
const {
|
|
getAdditionalEntries,
|
|
getIntegrationEntry,
|
|
getRefreshGlobal,
|
|
getSocketIntegration,
|
|
injectRefreshEntry,
|
|
injectRefreshLoader,
|
|
makeRefreshRuntimeModule,
|
|
normalizeOptions,
|
|
} = require('./utils');
|
|
const schema = require('./options.json');
|
|
|
|
class ReactRefreshPlugin {
|
|
/**
|
|
* @param {import('./types').ReactRefreshPluginOptions} [options] Options for react-refresh-plugin.
|
|
*/
|
|
constructor(options = {}) {
|
|
validateOptions(schema, options, {
|
|
name: 'React Refresh Plugin',
|
|
baseDataPath: 'options',
|
|
});
|
|
|
|
/**
|
|
* @readonly
|
|
* @type {import('./types').NormalizedPluginOptions}
|
|
*/
|
|
this.options = normalizeOptions(options);
|
|
}
|
|
|
|
/**
|
|
* Applies the plugin.
|
|
* @param {import('webpack').Compiler} compiler A webpack compiler object.
|
|
* @returns {void}
|
|
*/
|
|
apply(compiler) {
|
|
// Skip processing in non-development mode, but allow manual force-enabling
|
|
if (
|
|
// Webpack do not set process.env.NODE_ENV, so we need to check for mode.
|
|
// Ref: https://github.com/webpack/webpack/issues/7074
|
|
(compiler.options.mode !== 'development' ||
|
|
// We also check for production process.env.NODE_ENV,
|
|
// in case it was set and mode is non-development (e.g. 'none')
|
|
(process.env.NODE_ENV && process.env.NODE_ENV === 'production')) &&
|
|
!this.options.forceEnable
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const webpackVersion = getWebpackVersion(compiler);
|
|
const logger = compiler.getInfrastructureLogger(this.constructor.name);
|
|
|
|
// Get Webpack imports from compiler instance (if available) -
|
|
// this allow mono-repos to use different versions of Webpack without conflicts.
|
|
const webpack = compiler.webpack || require('webpack');
|
|
const {
|
|
DefinePlugin,
|
|
EntryDependency,
|
|
EntryPlugin,
|
|
ModuleFilenameHelpers,
|
|
NormalModule,
|
|
ProvidePlugin,
|
|
RuntimeGlobals,
|
|
Template,
|
|
} = webpack;
|
|
|
|
// Inject react-refresh context to all Webpack entry points.
|
|
// This should create `EntryDependency` objects when available,
|
|
// and fallback to patching the `entry` object for legacy workflows.
|
|
const addEntries = getAdditionalEntries({
|
|
devServer: compiler.options.devServer,
|
|
options: this.options,
|
|
});
|
|
if (EntryPlugin) {
|
|
// Prepended entries does not care about injection order,
|
|
// so we can utilise EntryPlugin for simpler logic.
|
|
addEntries.prependEntries.forEach((entry) => {
|
|
new EntryPlugin(compiler.context, entry, { name: undefined }).apply(compiler);
|
|
});
|
|
|
|
const integrationEntry = getIntegrationEntry(this.options.overlay.sockIntegration);
|
|
const socketEntryData = [];
|
|
compiler.hooks.make.tap(
|
|
{ name: this.constructor.name, stage: Number.POSITIVE_INFINITY },
|
|
(compilation) => {
|
|
// Exhaustively search all entries for `integrationEntry`.
|
|
// If found, mark those entries and the index of `integrationEntry`.
|
|
for (const [name, entryData] of compilation.entries.entries()) {
|
|
const index = entryData.dependencies.findIndex(
|
|
(dep) => dep.request && dep.request.includes(integrationEntry)
|
|
);
|
|
if (index !== -1) {
|
|
socketEntryData.push({ name, index });
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
// Overlay entries need to be injected AFTER integration's entry,
|
|
// so we will loop through everything in `finishMake` instead of `make`.
|
|
// This ensures we can traverse all entry points and inject stuff with the correct order.
|
|
addEntries.overlayEntries.forEach((entry, idx, arr) => {
|
|
compiler.hooks.finishMake.tapPromise(
|
|
{ name: this.constructor.name, stage: Number.MIN_SAFE_INTEGER + (arr.length - idx - 1) },
|
|
(compilation) => {
|
|
// Only hook into the current compiler
|
|
if (compilation.compiler !== compiler) {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
const injectData = socketEntryData.length ? socketEntryData : [{ name: undefined }];
|
|
return Promise.all(
|
|
injectData.map(({ name, index }) => {
|
|
return new Promise((resolve, reject) => {
|
|
const options = { name };
|
|
const dep = EntryPlugin.createDependency(entry, options);
|
|
compilation.addEntry(compiler.context, dep, options, (err) => {
|
|
if (err) return reject(err);
|
|
|
|
// If the entry is not a global one,
|
|
// and we have registered the index for integration entry,
|
|
// we will reorder all entry dependencies to our desired order.
|
|
// That is, to have additional entries DIRECTLY behind integration entry.
|
|
if (name && typeof index !== 'undefined') {
|
|
const entryData = compilation.entries.get(name);
|
|
entryData.dependencies.splice(
|
|
index + 1,
|
|
0,
|
|
entryData.dependencies.splice(entryData.dependencies.length - 1, 1)[0]
|
|
);
|
|
}
|
|
|
|
resolve();
|
|
});
|
|
});
|
|
})
|
|
).then(() => {});
|
|
}
|
|
);
|
|
});
|
|
} else {
|
|
compiler.options.entry = injectRefreshEntry(compiler.options.entry, addEntries);
|
|
}
|
|
|
|
// Inject necessary modules and variables to bundle's global scope
|
|
const refreshGlobal = getRefreshGlobalScope(RuntimeGlobals || {});
|
|
/** @type {Record<string, string | boolean>}*/
|
|
const definedModules = {
|
|
// Mapping of react-refresh globals to Webpack runtime globals
|
|
$RefreshReg$: `${refreshGlobal}.register`,
|
|
$RefreshSig$: `${refreshGlobal}.signature`,
|
|
'typeof $RefreshReg$': 'function',
|
|
'typeof $RefreshSig$': 'function',
|
|
|
|
// Library mode
|
|
__react_refresh_library__: JSON.stringify(
|
|
Template.toIdentifier(
|
|
this.options.library ||
|
|
compiler.options.output.uniqueName ||
|
|
compiler.options.output.library
|
|
)
|
|
),
|
|
};
|
|
/** @type {Record<string, string>} */
|
|
const providedModules = {
|
|
__react_refresh_utils__: require.resolve('./runtime/RefreshUtils'),
|
|
};
|
|
|
|
if (this.options.overlay === false) {
|
|
// Stub errorOverlay module so their calls can be erased
|
|
definedModules.__react_refresh_error_overlay__ = false;
|
|
definedModules.__react_refresh_polyfill_url__ = false;
|
|
definedModules.__react_refresh_socket__ = false;
|
|
} else {
|
|
definedModules.__react_refresh_polyfill_url__ = this.options.overlay.useURLPolyfill || false;
|
|
|
|
if (this.options.overlay.module) {
|
|
providedModules.__react_refresh_error_overlay__ = require.resolve(
|
|
this.options.overlay.module
|
|
);
|
|
}
|
|
if (this.options.overlay.sockIntegration) {
|
|
providedModules.__react_refresh_socket__ = getSocketIntegration(
|
|
this.options.overlay.sockIntegration
|
|
);
|
|
}
|
|
}
|
|
|
|
new DefinePlugin(definedModules).apply(compiler);
|
|
new ProvidePlugin(providedModules).apply(compiler);
|
|
|
|
const match = ModuleFilenameHelpers.matchObject.bind(undefined, this.options);
|
|
let loggedHotWarning = false;
|
|
compiler.hooks.compilation.tap(
|
|
this.constructor.name,
|
|
(compilation, { normalModuleFactory }) => {
|
|
// Only hook into the current compiler
|
|
if (compilation.compiler !== compiler) {
|
|
return;
|
|
}
|
|
|
|
// Tap into version-specific compilation hooks
|
|
switch (webpackVersion) {
|
|
case 4: {
|
|
const outputOptions = compilation.mainTemplate.outputOptions;
|
|
compilation.mainTemplate.hooks.require.tap(
|
|
this.constructor.name,
|
|
// Constructs the module template for react-refresh
|
|
(source, chunk, hash) => {
|
|
// Check for the output filename
|
|
// This is to ensure we are processing a JS-related chunk
|
|
let filename = outputOptions.filename;
|
|
if (typeof filename === 'function') {
|
|
// Only usage of the `chunk` property is documented by Webpack.
|
|
// However, some internal Webpack plugins uses other properties,
|
|
// so we also pass them through to be on the safe side.
|
|
filename = filename({
|
|
contentHashType: 'javascript',
|
|
chunk,
|
|
hash,
|
|
});
|
|
}
|
|
|
|
// Check whether the current compilation is outputting to JS,
|
|
// since other plugins can trigger compilations for other file types too.
|
|
// If we apply the transform to them, their compilation will break fatally.
|
|
// One prominent example of this is the HTMLWebpackPlugin.
|
|
// If filename is falsy, something is terribly wrong and there's nothing we can do.
|
|
if (!filename || !filename.includes('.js')) {
|
|
return source;
|
|
}
|
|
|
|
// Split template source code into lines for easier processing
|
|
const lines = source.split('\n');
|
|
// Webpack generates this line when the MainTemplate is called
|
|
const moduleInitializationLineNumber = lines.findIndex((line) =>
|
|
line.includes('modules[moduleId].call(')
|
|
);
|
|
// Unable to find call to module execution -
|
|
// this happens if the current module does not call MainTemplate.
|
|
// In this case, we will return the original source and won't mess with it.
|
|
if (moduleInitializationLineNumber === -1) {
|
|
return source;
|
|
}
|
|
|
|
const moduleInterceptor = Template.asString([
|
|
`${refreshGlobal}.setup(moduleId);`,
|
|
'try {',
|
|
Template.indent(lines[moduleInitializationLineNumber]),
|
|
'} finally {',
|
|
Template.indent(`${refreshGlobal}.cleanup(moduleId);`),
|
|
'}',
|
|
]);
|
|
|
|
return Template.asString([
|
|
...lines.slice(0, moduleInitializationLineNumber),
|
|
'',
|
|
outputOptions.strictModuleExceptionHandling
|
|
? Template.indent(moduleInterceptor)
|
|
: moduleInterceptor,
|
|
'',
|
|
...lines.slice(moduleInitializationLineNumber + 1, lines.length),
|
|
]);
|
|
}
|
|
);
|
|
|
|
compilation.mainTemplate.hooks.requireExtensions.tap(
|
|
this.constructor.name,
|
|
// Setup react-refresh globals as extensions to Webpack's require function
|
|
(source) => {
|
|
return Template.asString([source, '', getRefreshGlobal(Template)]);
|
|
}
|
|
);
|
|
|
|
normalModuleFactory.hooks.afterResolve.tap(
|
|
this.constructor.name,
|
|
// Add react-refresh loader to process files that matches specified criteria
|
|
(data) => {
|
|
return injectRefreshLoader(data, {
|
|
match,
|
|
options: { const: false, esModule: false },
|
|
});
|
|
}
|
|
);
|
|
|
|
compilation.hooks.normalModuleLoader.tap(
|
|
// `Number.POSITIVE_INFINITY` ensures this check will run only after all other taps
|
|
{ name: this.constructor.name, stage: Number.POSITIVE_INFINITY },
|
|
// Check for existence of the HMR runtime -
|
|
// it is the foundation to this plugin working correctly
|
|
(context) => {
|
|
if (!context.hot && !loggedHotWarning) {
|
|
logger.warn(
|
|
[
|
|
'Hot Module Replacement (HMR) is not enabled!',
|
|
'React Refresh requires HMR to function properly.',
|
|
].join(' ')
|
|
);
|
|
loggedHotWarning = true;
|
|
}
|
|
}
|
|
);
|
|
|
|
break;
|
|
}
|
|
case 5: {
|
|
// Set factory for EntryDependency which is used to initialise the module
|
|
compilation.dependencyFactories.set(EntryDependency, normalModuleFactory);
|
|
|
|
const ReactRefreshRuntimeModule = makeRefreshRuntimeModule(webpack);
|
|
compilation.hooks.additionalTreeRuntimeRequirements.tap(
|
|
this.constructor.name,
|
|
// Setup react-refresh globals with a Webpack runtime module
|
|
(chunk, runtimeRequirements) => {
|
|
runtimeRequirements.add(RuntimeGlobals.interceptModuleExecution);
|
|
runtimeRequirements.add(RuntimeGlobals.moduleCache);
|
|
runtimeRequirements.add(refreshGlobal);
|
|
compilation.addRuntimeModule(chunk, new ReactRefreshRuntimeModule());
|
|
}
|
|
);
|
|
|
|
normalModuleFactory.hooks.afterResolve.tap(
|
|
this.constructor.name,
|
|
// Add react-refresh loader to process files that matches specified criteria
|
|
(resolveData) => {
|
|
injectRefreshLoader(resolveData.createData, {
|
|
match,
|
|
options: {
|
|
const: compilation.runtimeTemplate.supportsConst(),
|
|
esModule: this.options.esModule,
|
|
},
|
|
});
|
|
}
|
|
);
|
|
|
|
NormalModule.getCompilationHooks(compilation).loader.tap(
|
|
// `Infinity` ensures this check will run only after all other taps
|
|
{ name: this.constructor.name, stage: Infinity },
|
|
// Check for existence of the HMR runtime -
|
|
// it is the foundation to this plugin working correctly
|
|
(context) => {
|
|
if (!context.hot && !loggedHotWarning) {
|
|
logger.warn(
|
|
[
|
|
'Hot Module Replacement (HMR) is not enabled!',
|
|
'React Refresh requires HMR to function properly.',
|
|
].join(' ')
|
|
);
|
|
loggedHotWarning = true;
|
|
}
|
|
}
|
|
);
|
|
|
|
break;
|
|
}
|
|
default: {
|
|
// Do nothing - this should be an impossible case
|
|
}
|
|
}
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
module.exports.ReactRefreshPlugin = ReactRefreshPlugin;
|
|
module.exports = ReactRefreshPlugin;
|