mirror of
https://github.com/facebook/react.git
synced 2025-11-01 09:12:30 +00:00
[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:
@@ -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};`;
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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%",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -22,7 +22,7 @@ babelRegister({
|
||||
return false;
|
||||
},
|
||||
],
|
||||
presets: ['react-app'],
|
||||
presets: ['@babel/preset-react'],
|
||||
plugins: ['@babel/transform-modules-commonjs'],
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"use client";
|
||||
// CJS-ESM async module
|
||||
module.exports = import('../Counter.js').then(m => {
|
||||
return m.Counter
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type":"commonjs"
|
||||
}
|
||||
+1088
-1214
File diff suppressed because it is too large
Load Diff
+13
-1
@@ -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> =
|
||||
|
||||
+38
-34
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user