[Flight] Instrument the Promise for Async Module instead of using a Module Cache (#26985)

Currently, since we use a module cache for async modules, it doesn't
automatically get updated when the module registry gets updated (HMR).

This technique ensures that if Webpack replaces the module (HMR) then
we'll get the new Promise when we require it again.

This technique doesn't work for ESM and probably not Vite since ESM will
provide a new Promise each time you call `import()` but in the
Webpack/CJS approach this Promise is an entry in the module cache and
not a promise for the entry.

I tried to replicate the original issue in the fixture but it's tricky
to replicate because 1) we can't really use async modules the same way
without compiling both server and client 2) even then I'm not quite sure
how to repro the HMR issue.
This commit is contained in:
Sebastian Markbåge
2023-06-28 14:30:09 -04:00
committed by GitHub
parent a1c62b8a76
commit 5945e068ab
13 changed files with 1156 additions and 1377 deletions
@@ -1,29 +0,0 @@
'use strict';
const babelJest = require('babel-jest').default;
const hasJsxRuntime = (() => {
if (process.env.DISABLE_NEW_JSX_TRANSFORM === 'true') {
return false;
}
try {
require.resolve('react/jsx-runtime');
return true;
} catch (e) {
return false;
}
})();
module.exports = babelJest.createTransformer({
presets: [
[
require.resolve('babel-preset-react-app'),
{
runtime: hasJsxRuntime ? 'automatic' : 'classic',
},
],
],
babelrc: false,
configFile: false,
});
@@ -1,14 +0,0 @@
'use strict';
// This is a custom Jest transformer turning style imports into empty objects.
// http://facebook.github.io/jest/docs/en/webpack.html
module.exports = {
process() {
return 'module.exports = {};';
},
getCacheKey() {
// The output is always the same.
return 'cssTransform';
},
};
@@ -1,40 +0,0 @@
'use strict';
const path = require('path');
const camelcase = require('camelcase');
// This is a custom Jest transformer turning file imports into filenames.
// http://facebook.github.io/jest/docs/en/webpack.html
module.exports = {
process(src, filename) {
const assetFilename = JSON.stringify(path.basename(filename));
if (filename.match(/\.svg$/)) {
// Based on how SVGR generates a component name:
// https://github.com/smooth-code/svgr/blob/01b194cf967347d43d4cbe6b434404731b87cf27/packages/core/src/state.js#L6
const pascalCaseFilename = camelcase(path.parse(filename).name, {
pascalCase: true,
});
const componentName = `Svg${pascalCaseFilename}`;
return `const React = require('react');
module.exports = {
__esModule: true,
default: ${assetFilename},
ReactComponent: React.forwardRef(function ${componentName}(props, ref) {
return {
$$typeof: Symbol.for('react.element'),
type: 'svg',
ref: ref,
key: null,
props: Object.assign({}, props, {
children: ${assetFilename}
})
};
}),
};`;
}
return `module.exports = ${assetFilename};`;
},
};
+3 -29
View File
@@ -15,7 +15,6 @@ const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
const ModuleScopePlugin = require('react-dev-utils/ModuleScopePlugin');
const getCSSModuleLocalIdent = require('react-dev-utils/getCSSModuleLocalIdent');
const ESLintPlugin = require('eslint-webpack-plugin');
const paths = require('./paths');
const modules = require('./modules');
const getClientEnvironment = require('./env');
@@ -54,9 +53,6 @@ const babelRuntimeRegenerator = require.resolve('@babel/runtime/regenerator', {
// makes for a smoother build process.
const shouldInlineRuntimeChunk = process.env.INLINE_RUNTIME_CHUNK !== 'false';
const emitErrorsAsWarnings = process.env.ESLINT_NO_DEV_ERRORS === 'true';
const disableESLintPlugin = process.env.DISABLE_ESLINT_PLUGIN === 'true';
const imageInlineSizeLimit = parseInt(
process.env.IMAGE_INLINE_SIZE_LIMIT || '10000'
);
@@ -687,31 +683,6 @@ module.exports = function (webpackEnv) {
infrastructure: 'silent',
},
}),
// !disableESLintPlugin &&
// new ESLintPlugin({
// // Plugin options
// extensions: ['js', 'mjs', 'jsx', 'ts', 'tsx'],
// formatter: require.resolve('react-dev-utils/eslintFormatter'),
// eslintPath: require.resolve('eslint'),
// failOnError: !(isEnvDevelopment && emitErrorsAsWarnings),
// context: paths.appSrc,
// cache: true,
// cacheLocation: path.resolve(
// paths.appNodeModules,
// '.cache/.eslintcache'
// ),
// // ESLint class options
// cwd: paths.appPath,
// resolvePluginsRelativeTo: __dirname,
// baseConfig: {
// extends: [require.resolve('eslint-config-react-app/base')],
// rules: {
// ...(!hasJsxRuntime && {
// 'react/react-in-jsx-scope': 'error',
// }),
// },
// },
// }),
// Fork Start
new ReactFlightWebpackPlugin({
isServer: false,
@@ -723,6 +694,9 @@ module.exports = function (webpackEnv) {
}),
// Fork End
].filter(Boolean),
experiments: {
topLevelAwait: true,
},
// Turn off performance processing because we utilize
// our own hints via the FileSizeReporter
performance: false,
+1 -10
View File
@@ -6,6 +6,7 @@
"dependencies": {
"@babel/core": "^7.16.0",
"@babel/plugin-proposal-private-property-in-object": "^7.18.6",
"@babel/preset-react": "^7.22.5",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.3",
"@svgr/webpack": "^5.5.0",
"@testing-library/jest-dom": "^5.14.1",
@@ -26,9 +27,6 @@
"css-minimizer-webpack-plugin": "^3.2.0",
"dotenv": "^10.0.0",
"dotenv-expand": "^5.1.0",
"eslint": "^8.3.0",
"eslint-config-react-app": "^7.0.1",
"eslint-webpack-plugin": "^3.1.1",
"file-loader": "^6.2.0",
"fs-extra": "^10.0.0",
"html-webpack-plugin": "^5.5.0",
@@ -45,7 +43,6 @@
"postcss-preset-env": "^7.0.1",
"prompts": "^2.4.2",
"react": "experimental",
"react-app-polyfill": "^3.0.0",
"react-dev-utils": "^12.0.1",
"react-dom": "experimental",
"react-refresh": "^0.11.0",
@@ -75,12 +72,6 @@
"build": "node scripts/build.js",
"test": "node scripts/test.js --env=jsdom"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
+1 -5
View File
@@ -7,9 +7,6 @@ const path = require('path');
// Do this as the first thing so that any code reading it knows the right env.
process.env.BABEL_ENV = process.env.NODE_ENV;
const register = require('react-server-dom-webpack/node-register');
register();
const babelRegister = require('@babel/register');
babelRegister({
babelrc: false,
@@ -25,8 +22,7 @@ babelRegister({
return false;
},
],
presets: ['react-app'],
plugins: ['@babel/transform-modules-commonjs'],
presets: ['@babel/preset-react'],
});
// Ensure environment variables are read.
+1 -1
View File
@@ -22,7 +22,7 @@ babelRegister({
return false;
},
],
presets: ['react-app'],
presets: ['@babel/preset-react'],
plugins: ['@babel/transform-modules-commonjs'],
});
+3
View File
@@ -4,6 +4,8 @@ import Container from './Container.js';
import {Counter} from './Counter.js';
import {Counter as Counter2} from './Counter2.js';
import AsyncModule from './cjs/Counter3.js';
const Counter3 = await(AsyncModule);
import ShowMore from './ShowMore.js';
import Button from './Button.js';
@@ -28,6 +30,7 @@ export default async function App() {
<h1>{getServerState()}</h1>
<Counter />
<Counter2 />
<Counter3 />
<ul>
{todos.map(todo => (
<li key={todo.id}>{todo.text}</li>
+5
View File
@@ -0,0 +1,5 @@
"use client";
// CJS-ESM async module
module.exports = import('../Counter.js').then(m => {
return m.Counter
});
+3
View File
@@ -0,0 +1,3 @@
{
"type":"commonjs"
}
+1088 -1214
View File
File diff suppressed because it is too large Load Diff
@@ -27,12 +27,14 @@ export opaque type ClientReferenceMetadata = {
id: string,
chunks: Array<string>,
name: string,
async?: boolean,
};
// eslint-disable-next-line no-unused-vars
export opaque type ClientReference<T> = {
specifier: string,
name: string,
async?: boolean,
};
export function resolveClientReference<T>(
@@ -61,6 +63,7 @@ export function resolveClientReference<T>(
return {
specifier: resolvedModuleData.specifier,
name: name,
async: metadata.async,
};
}
@@ -87,7 +90,16 @@ export function preloadModule<T>(
return existingPromise;
} else {
// $FlowFixMe[unsupported-syntax]
const modulePromise: Thenable<T> = import(metadata.specifier);
let modulePromise: Promise<T> = import(metadata.specifier);
if (metadata.async) {
// If the module is async, it must have been a CJS module.
// CJS modules are accessed through the default export in
// Node.js so we have to get the default export to get the
// full module exports.
modulePromise = modulePromise.then(function (value) {
return (value: any).default;
});
}
modulePromise.then(
value => {
const fulfilledThenable: FulfilledThenable<mixed> =
@@ -111,7 +111,33 @@ export function resolveServerReference<T>(
// in Webpack but unfortunately it's not exposed so we have to
// replicate it in user space. null means that it has already loaded.
const chunkCache: Map<string, null | Promise<any>> = new Map();
const asyncModuleCache: Map<string, Thenable<any>> = new Map();
function requireAsyncModule(id: string): null | Thenable<any> {
// We've already loaded all the chunks. We can require the module.
const promise = __webpack_require__(id);
if (typeof promise.then !== 'function') {
// This wasn't a promise after all.
return null;
} else if (promise.status === 'fulfilled') {
// This module was already resolved earlier.
return null;
} else {
// Instrument the Promise to stash the result.
promise.then(
value => {
const fulfilledThenable: FulfilledThenable<mixed> = (promise: any);
fulfilledThenable.status = 'fulfilled';
fulfilledThenable.value = value;
},
reason => {
const rejectedThenable: RejectedThenable<mixed> = (promise: any);
rejectedThenable.status = 'rejected';
rejectedThenable.reason = reason;
},
);
return promise;
}
}
function ignoreReject() {
// We rely on rejected promises to be handled by another listener.
@@ -138,32 +164,12 @@ export function preloadModule<T>(
}
}
if (metadata.async) {
const existingPromise = asyncModuleCache.get(metadata.id);
if (existingPromise) {
if (existingPromise.status === 'fulfilled') {
return null;
}
return existingPromise;
if (promises.length === 0) {
return requireAsyncModule(metadata.id);
} else {
const modulePromise: Thenable<T> = Promise.all(promises).then(() => {
return __webpack_require__(metadata.id);
return Promise.all(promises).then(() => {
return requireAsyncModule(metadata.id);
});
modulePromise.then(
value => {
const fulfilledThenable: FulfilledThenable<mixed> =
(modulePromise: any);
fulfilledThenable.status = 'fulfilled';
fulfilledThenable.value = value;
},
reason => {
const rejectedThenable: RejectedThenable<mixed> =
(modulePromise: any);
rejectedThenable.status = 'rejected';
rejectedThenable.reason = reason;
},
);
asyncModuleCache.set(metadata.id, modulePromise);
return modulePromise;
}
} else if (promises.length > 0) {
return Promise.all(promises);
@@ -175,18 +181,16 @@ export function preloadModule<T>(
// Actually require the module or suspend if it's not yet ready.
// Increase priority if necessary.
export function requireModule<T>(metadata: ClientReference<T>): T {
let moduleExports;
let moduleExports = __webpack_require__(metadata.id);
if (metadata.async) {
// We assume that preloadModule has been called before, which
// should have added something to the module cache.
const promise: any = asyncModuleCache.get(metadata.id);
if (promise.status === 'fulfilled') {
moduleExports = promise.value;
if (typeof moduleExports.then !== 'function') {
// This wasn't a promise after all.
} else if (moduleExports.status === 'fulfilled') {
// This Promise should've been instrumented by preloadModule.
moduleExports = moduleExports.value;
} else {
throw promise.reason;
throw moduleExports.reason;
}
} else {
moduleExports = __webpack_require__(metadata.id);
}
if (metadata.name === '*') {
// This is a placeholder value that represents that the caller imported this