Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ jobs:
run: yarn install --frozen-lockfile
- name: Check Linting
run: yarn lint
- name: Check Types
run: yarn types:check
- name: Check Formatting
run: yarn format:check

Expand Down
129 changes: 73 additions & 56 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const { getRefreshGlobalScope } = require('./globals');
const {
getAdditionalEntries,
getIntegrationEntry,
getLibraryNamespace,
getSocketIntegration,
injectRefreshLoader,
makeRefreshRuntimeModule,
Expand All @@ -15,7 +16,7 @@ class ReactRefreshPlugin {
* @param {import('./types').ReactRefreshPluginOptions} [options] Options for react-refresh-plugin.
*/
constructor(options = {}) {
validateOptions(schema, options, {
validateOptions(/** @type {Parameters<typeof validateOptions>[0]} */ (schema), options, {
name: 'React Refresh Plugin',
baseDataPath: 'options',
});
Expand Down Expand Up @@ -53,13 +54,13 @@ class ReactRefreshPlugin {
const webpack = compiler.webpack || require('webpack');
const {
DefinePlugin,
EntryDependency,
EntryPlugin,
ModuleFilenameHelpers,
NormalModule,
ProvidePlugin,
RuntimeGlobals,
Template,
dependencies: { ModuleDependency },
} = webpack;

// Inject react-refresh context to all Webpack entry points.
Expand All @@ -70,23 +71,33 @@ class ReactRefreshPlugin {
new EntryPlugin(compiler.context, entry, { name: undefined }).apply(compiler);
}

const integrationEntry = getIntegrationEntry(this.options.overlay.sockIntegration);
/** @type {{ name?: string; index?: number }[]} */
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 });

/** @type {string | undefined} */
let integrationEntry;
if (this.options.overlay && this.options.overlay.sockIntegration) {
integrationEntry = getIntegrationEntry(this.options.overlay.sockIntegration);
}

if (integrationEntry) {
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) => {
if (!(dep instanceof ModuleDependency)) return false;
return 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`.
Expand All @@ -97,37 +108,39 @@ class ReactRefreshPlugin {
name: this.constructor.name,
stage: Number.MIN_SAFE_INTEGER + (overlayEntries.length - idx - 1),
},
(compilation) => {
async (compilation) => {
// Only hook into the current compiler
if (compilation.compiler !== compiler) {
return Promise.resolve();
}
if (compilation.compiler !== compiler) return;

const injectData = socketEntryData.length ? socketEntryData : [{ name: undefined }];
return Promise.all(
await 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();
});
});
return /** @type {Promise<void>} */ (
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);
if (entryData) {
entryData.dependencies.splice(
index + 1,
0,
entryData.dependencies.splice(entryData.dependencies.length - 1, 1)[0]
);
}
}

resolve();
});
})
);
})
);
}
Expand All @@ -143,21 +156,20 @@ class ReactRefreshPlugin {
$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'),
};

// Library mode
const libraryNamespace = getLibraryNamespace(this.options, compiler.options.output);
if (libraryNamespace) {
definedModules['__react_refresh_library__'] = JSON.stringify(
Template.toIdentifier(libraryNamespace)
);
}

if (this.options.overlay === false) {
// Stub errorOverlay module so their calls can be erased
definedModules.__react_refresh_error_overlay__ = false;
Expand All @@ -178,7 +190,15 @@ class ReactRefreshPlugin {
new DefinePlugin(definedModules).apply(compiler);
new ProvidePlugin(providedModules).apply(compiler);

const match = ModuleFilenameHelpers.matchObject.bind(undefined, this.options);
/**
* @param {string} [str]
* @returns boolean
*/
const match = (str) => {
if (str == null) return false;
return ModuleFilenameHelpers.matchObject(this.options, str);
};

let loggedHotWarning = false;
compiler.hooks.compilation.tap(
this.constructor.name,
Expand All @@ -188,9 +208,6 @@ class ReactRefreshPlugin {
return;
}

// 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,
Expand Down
2 changes: 1 addition & 1 deletion lib/utils/getAdditionalEntries.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ function getAdditionalEntries(options) {
const overlayEntries = [
// Error overlay runtime
options.overlay && options.overlay.entry && require.resolve(options.overlay.entry),
].filter(Boolean);
].filter((x) => typeof x === 'string');

return { prependEntries, overlayEntries };
}
Expand Down
20 changes: 20 additions & 0 deletions lib/utils/getLibraryNamespace.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Gets the library namespace for React Refresh injection.
* @param {import('../types').NormalizedPluginOptions} pluginOptions Configuration options for this plugin.
* @param {import('webpack').Compilation["options"]["output"]} outputOptions Configuration options for Webpack output.
* @returns {string | undefined} The library namespace for React Refresh injection.
*/
function getLibraryNamespace(pluginOptions, outputOptions) {
if (pluginOptions.library) return pluginOptions.library;
if (outputOptions.uniqueName) return outputOptions.uniqueName;

if (!outputOptions.library || !outputOptions.library.name) return;

const libraryName = outputOptions.library.name;
if (Array.isArray(libraryName)) return libraryName[0];
if (typeof libraryName === 'string') return libraryName;
if (Array.isArray(libraryName.root)) return libraryName.root[0];
if (typeof libraryName.root === 'string') return libraryName.root;
}

module.exports = getLibraryNamespace;
2 changes: 2 additions & 0 deletions lib/utils/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const getAdditionalEntries = require('./getAdditionalEntries');
const getIntegrationEntry = require('./getIntegrationEntry');
const getLibraryNamespace = require('./getLibraryNamespace');
const getSocketIntegration = require('./getSocketIntegration');
const injectRefreshLoader = require('./injectRefreshLoader');
const makeRefreshRuntimeModule = require('./makeRefreshRuntimeModule');
Expand All @@ -8,6 +9,7 @@ const normalizeOptions = require('./normalizeOptions');
module.exports = {
getAdditionalEntries,
getIntegrationEntry,
getLibraryNamespace,
getSocketIntegration,
injectRefreshLoader,
makeRefreshRuntimeModule,
Expand Down
24 changes: 17 additions & 7 deletions lib/utils/injectRefreshLoader.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,38 +18,48 @@ const refreshUtilsPath = path.join(__dirname, '../runtime/RefreshUtils');

/**
* Injects refresh loader to all JavaScript-like and user-specified files.
* @param {*} moduleData Module factory creation data.
* @param {import('webpack').ResolveData["createData"]} moduleData Module factory creation data.
* @param {InjectLoaderOptions} injectOptions Options to alter how the loader is injected.
* @returns {*} The injected module factory creation data.
* @returns {import('webpack').ResolveData["createData"]} The injected module factory creation data.
*/
function injectRefreshLoader(moduleData, injectOptions) {
const { match, options } = injectOptions;

// Include and exclude user-specified files
if (!match(moduleData.matchResource || moduleData.resource)) return moduleData;
if (!match(moduleData.matchResource || moduleData.resource || '')) return moduleData;
// Include and exclude dynamically generated modules from other loaders
if (moduleData.matchResource && !match(moduleData.request)) return moduleData;
// Exclude files referenced as assets
if (moduleData.type.includes('asset')) return moduleData;
if (moduleData.type != null && moduleData.type.includes('asset')) return moduleData;
// Check to prevent double injection
if (moduleData.loaders.find(({ loader }) => loader === resolvedLoader)) return moduleData;
if (
moduleData.loaders != null &&
moduleData.loaders.find(({ loader }) => loader === resolvedLoader)
) {
return moduleData;
}
// Skip react-refresh and the plugin's runtime utils to prevent self-referencing -
// this is useful when using the plugin as a direct dependency,
// or when node_modules are specified to be processed.
if (
moduleData.resource.includes(reactRefreshPath) ||
moduleData.resource.includes(refreshUtilsPath)
moduleData.resource != null &&
(moduleData.resource.includes(reactRefreshPath) ||
moduleData.resource.includes(refreshUtilsPath))
) {
return moduleData;
}

if (moduleData.loaders == null) moduleData.loaders = [];

// As we inject runtime code for each module,
// it is important to run the injected loader after everything.
// This way we can ensure that all code-processing have been done,
// and we won't risk breaking tools like Flow or ESLint.
moduleData.loaders.unshift({
loader: resolvedLoader,
options,
ident: null,
type: null,
});

return moduleData;
Expand Down
2 changes: 1 addition & 1 deletion lib/utils/makeRefreshRuntimeModule.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* This module creates an isolated `__webpack_require__` function for each module,
* and injects a `$Refresh$` object into it for use by React Refresh.
* @param {import('webpack')} webpack The Webpack exports.
* @returns {typeof import('webpack').RuntimeModule} The runtime module class.
* @returns {new () => import('webpack').RuntimeModule} The runtime module class.
*/
function makeRefreshRuntimeModule(webpack) {
return class ReactRefreshRuntimeModule extends webpack.RuntimeModule {
Expand Down
2 changes: 1 addition & 1 deletion lib/utils/normalizeOptions.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const normalizeOptions = (options) => {
return overlay;
});

return options;
return /** @type {import('../types').NormalizedPluginOptions} */ (options);
};

module.exports = normalizeOptions;
16 changes: 11 additions & 5 deletions loader/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// That check, however, will break when `fetch` polyfills are used for SSR setups.
// We "reset" the polyfill here to ensure it won't interfere with source-map generation.
const originalFetch = global.fetch;
// @ts-expect-error
delete global.fetch;

const { validate: validateOptions } = require('schema-utils');
Expand Down Expand Up @@ -31,17 +32,22 @@ const RefreshRuntimePath = require
* @returns {void}
*/
function ReactRefreshLoader(source, inputSourceMap, meta) {
let options = this.getOptions();
validateOptions(schema, options, {
const _options = this.getOptions();
validateOptions(/** @type {Parameters<typeof validateOptions>[0]} */ (schema), _options, {
baseDataPath: 'options',
name: 'React Refresh Loader',
});

options = normalizeOptions(options);
const options = normalizeOptions(_options);

const callback = this.async();

const { ModuleFilenameHelpers, Template } = this._compiler.webpack || require('webpack');
/** @type {import('webpack')} */
let webpack;
if (this._compiler != null && this._compiler.webpack) webpack = this._compiler.webpack;
else webpack = require('webpack');

const { ModuleFilenameHelpers, Template } = webpack;

const RefreshSetupRuntimes = {
cjs: Template.asString(
Expand All @@ -57,7 +63,7 @@ function ReactRefreshLoader(source, inputSourceMap, meta) {
* @this {import('webpack').LoaderContext<import('./types').ReactRefreshLoaderOptions>}
* @param {string} source
* @param {import('source-map').RawSourceMap} [inputSourceMap]
* @returns {Promise<[string, import('source-map').RawSourceMap]>}
* @returns {Promise<[string, import('source-map').RawSourceMap?]>}
*/
async function _loader(source, inputSourceMap) {
/** @type {'esm' | 'cjs'} */
Expand Down
4 changes: 2 additions & 2 deletions loader/utils/getModuleSystem.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ let packageJsonTypeMap = new Map();

/**
* Infers the current active module system from loader context and options.
* @this {import('webpack').loader.LoaderContext}
* @this {import('webpack').LoaderContext<import('../types').ReactRefreshLoaderOptions>}
* @param {import('webpack').ModuleFilenameHelpers} ModuleFilenameHelpers Webpack's module filename helpers.
* @param {import('../types').NormalizedLoaderOptions} options The normalized loader options.
* @return {Promise<'esm' | 'cjs'>} The inferred module system.
Expand Down Expand Up @@ -73,7 +73,7 @@ async function getModuleSystem(ModuleFilenameHelpers, options) {
// from the `resourcePath` folder up to the matching `searchPath`,
// to avoid retracing these steps when processing sibling resources.
if (packageJsonTypeMap.has(searchPath)) {
packageJsonType = packageJsonTypeMap.get(searchPath);
packageJsonType = /** @type {string} */ (packageJsonTypeMap.get(searchPath));

let currentPath = resourceContext;
while (currentPath !== searchPath) {
Expand Down
Loading