diff --git a/packages/react-scripts/config/env.js b/packages/react-scripts/config/env.js index 7565cecd001..4c0fd54656a 100644 --- a/packages/react-scripts/config/env.js +++ b/packages/react-scripts/config/env.js @@ -9,7 +9,6 @@ 'use strict'; const fs = require('fs'); -const path = require('path'); const paths = require('./paths'); // Make sure that including paths.js after env.js will read .env variables. @@ -48,21 +47,14 @@ dotenvFiles.forEach(dotenvFile => { } }); -// We support resolving modules according to `NODE_PATH`. +// We used to support resolving modules according to `NODE_PATH`. +// This now has been deprecated in favor of jsconfig/tsconfig.json // This lets you use absolute paths in imports inside large monorepos: -// https://github.com/facebook/create-react-app/issues/253. -// It works similar to `NODE_PATH` in Node itself: -// https://nodejs.org/api/modules.html#modules_loading_from_the_global_folders -// Note that unlike in Node, only *relative* paths from `NODE_PATH` are honored. -// Otherwise, we risk importing Node.js core modules into an app instead of Webpack shims. -// https://github.com/facebook/create-react-app/issues/1023#issuecomment-265344421 -// We also resolve them to make sure all tools using them work consistently. -const appDirectory = fs.realpathSync(process.cwd()); -process.env.NODE_PATH = (process.env.NODE_PATH || '') - .split(path.delimiter) - .filter(folder => folder && !path.isAbsolute(folder)) - .map(folder => path.resolve(appDirectory, folder)) - .join(path.delimiter); +if (process.env.NODE_PATH) { + console.log( + 'Setting NODE_PATH to resolves modules absolutely has been deprecated in favor of setting baseUrl in jsconfig.json (or tsconfig.json if you are using Typescript) to achieve the same behaviour.' + ); +} // Grab NODE_ENV and REACT_APP_* environment variables and prepare them to be // injected into the application via DefinePlugin in Webpack configuration. diff --git a/packages/react-scripts/config/modules.js b/packages/react-scripts/config/modules.js new file mode 100644 index 00000000000..b9147cb2855 --- /dev/null +++ b/packages/react-scripts/config/modules.js @@ -0,0 +1,183 @@ +// @remove-on-eject-begin +/** + * Copyright (c) 2015-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +// @remove-on-eject-end +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const chalk = require('chalk'); +const paths = require('./paths'); + +/** + * Get the baseUrl of a compilerOptions object. + * + * @param {Object} options + */ +function getAdditionalModulePath(options = {}) { + const baseUrl = options.baseUrl; + + // We need to explicitly check for null and undefined (and not a falsy value) because + // TypeScript treats an empty string as `.`. + if (baseUrl == null) { + return null; + } + + const baseUrlResolved = path.resolve(paths.appPath, baseUrl); + + // We don't need to do anything if `baseUrl` is set to `node_modules`. This is + // the default behavior. + if (path.relative(paths.appNodeModules, baseUrlResolved) === '') { + return null; + } + + // Allow the user set the `baseUrl` to `appSrc`. + if (path.relative(paths.appSrc, baseUrlResolved) === '') { + return paths.appSrc; + } + + // Otherwise, throw an error. + throw new Error( + chalk.red.bold( + "Your project's `baseUrl` can only be set to `src` or `node_modules`." + + ' Create React App does not support other values at this time.' + ) + ); +} + +/** + * Get the alias of a compilerOptions object. + * + * @param {Object} options + */ +function getAliases(options = {}) { + // This is an object with the alias as key + // and an array of paths as value. + // e.g. '@': ['src'] + const aliases = options.paths || {}; + + return Object.keys(aliases).reduce(function(prev, alias) { + // Value contains the paths of the alias. + const value = aliases[alias]; + + // The value should be an array but we have to verify + // that because it's user input. + if (!Array.isArray(value) || value.length > 1) { + throw new Error( + chalk.red.bold( + "Your project's `alias` can only be set to an array containing `src` or a subfolder of `src`." + + ' Create React App does not support other values at this time.' + ) + ); + } + + const aliasPath = value[0]; + + // Alias paths are relative to the baseurl. + // If there is no baseUrl set, it will default to the root of the app. + const baseUrl = options.baseUrl + ? path.resolve(paths.appPath, options.baseUrl) + : paths.appPath; + const resolvedAliasPath = path.resolve(baseUrl, aliasPath); + + // We then check if the resolved alias path is src or a sub folder of src. + const relativePath = path.relative(paths.appSrc, resolvedAliasPath); + const isSrc = relativePath === ''; + const isSubfolderOfSrc = + !relativePath.startsWith('../') && !relativePath.startsWith('..\\'); + + if (!isSrc && !isSubfolderOfSrc) { + throw new Error( + chalk.red.bold( + "Your project's `alias` can only be set to ['src'] or a subfolder of `src`." + + ' Create React App does not support other values at this time.' + ) + ); + } + + prev[alias] = resolvedAliasPath; + return prev; + }, {}); +} + +function getWebpackAliases(aliases) { + return Object.keys(aliases).reduce(function(prev, alias) { + let aliasPath = aliases[alias]; + const endsWithWilcard = alias.endsWith('*'); + // Remove trailing wildcards (/*) + alias = alias.replace(/\/?\*$/, ''); + aliasPath = aliasPath.replace(/\/\*$/, ''); + // Webpack aliases work a little bit different than jsconfig/tsconfig.json paths + // By default webpack aliases act as a wildcard and for an exact match you have + // to suffix it with a dollar sign. + // tsconfig/jsconfig.json work the other way around and are an exact match unless + // suffixed by a wildcard. + const webpackAlias = endsWithWilcard ? alias : alias + '$'; + prev[webpackAlias] = aliasPath; + return prev; + }, {}); +} + +function getJestAliases(aliases) { + return Object.keys(aliases).reduce(function(prev, alias) { + const endsWithWilcard = alias.endsWith('*'); + let aliasPath = aliases[alias]; + + alias = alias.replace(/\/?\*$/, ''); + const match = endsWithWilcard ? alias + '(.*)$' : alias; + + aliasPath = aliasPath.replace(/\*$/, ''); + const relativeAliasPath = path.relative(paths.appPath, aliasPath); + const target = '/' + relativeAliasPath; + + prev[match] = target + (endsWithWilcard ? '/$1' : ''); + return prev; + }, {}); +} + +function getModules() { + // Check if TypeScript is setup + const useTypeScript = fs.existsSync(paths.appTsConfig); + const hasJsConfig = fs.existsSync(paths.appJsConfig); + + if (useTypeScript && hasJsConfig) { + throw new Error( + 'You have both a tsconfig.json and a jsconfig.json. If you are using Typescript please remove your jsconfig.json file.' + ); + } + + let config; + + // If there's a tsconfig.json we assume it's a + // Typescript project and set up the config + // based on tsconfig.json + if (useTypeScript) { + config = require(paths.appTsConfig); + // Otherwise we'll check if there is jsconfig.json + // for non TS projects. + } else if (hasJsConfig) { + config = require(paths.appJsConfig); + } + + config = config || {}; + const options = config.compilerOptions || {}; + + const aliases = getAliases(options); + const jestAliases = getJestAliases(aliases); + const webpackAliases = getWebpackAliases(aliases); + const additionalModulePath = getAdditionalModulePath(options); + + return { + aliases: aliases, + jestAliases: jestAliases, + webpackAliases: webpackAliases, + additionalModulePath: additionalModulePath, + useTypeScript, + }; +} + +module.exports = getModules(); diff --git a/packages/react-scripts/config/paths.js b/packages/react-scripts/config/paths.js index b719054583b..e5a3e0b5374 100644 --- a/packages/react-scripts/config/paths.js +++ b/packages/react-scripts/config/paths.js @@ -84,6 +84,7 @@ module.exports = { appPackageJson: resolveApp('package.json'), appSrc: resolveApp('src'), appTsConfig: resolveApp('tsconfig.json'), + appJsConfig: resolveApp('jsconfig.json'), yarnLockFile: resolveApp('yarn.lock'), testsSetup: resolveModule(resolveApp, 'src/setupTests'), proxySetup: resolveApp('src/setupProxy.js'), @@ -106,6 +107,7 @@ module.exports = { appPackageJson: resolveApp('package.json'), appSrc: resolveApp('src'), appTsConfig: resolveApp('tsconfig.json'), + appJsConfig: resolveApp('jsconfig.json'), yarnLockFile: resolveApp('yarn.lock'), testsSetup: resolveModule(resolveApp, 'src/setupTests'), proxySetup: resolveApp('src/setupProxy.js'), @@ -140,6 +142,7 @@ if ( appPackageJson: resolveOwn('package.json'), appSrc: resolveOwn('template/src'), appTsConfig: resolveOwn('template/tsconfig.json'), + appJsConfig: resolveOwn('template/jsconfig.json'), yarnLockFile: resolveOwn('template/yarn.lock'), testsSetup: resolveModule(resolveOwn, 'template/src/setupTests'), proxySetup: resolveOwn('template/src/setupProxy.js'), diff --git a/packages/react-scripts/config/webpack.config.js b/packages/react-scripts/config/webpack.config.js index 3696597fb44..2c02bbffcf0 100644 --- a/packages/react-scripts/config/webpack.config.js +++ b/packages/react-scripts/config/webpack.config.js @@ -30,6 +30,7 @@ const paths = require('./paths'); const getClientEnvironment = require('./env'); const ModuleNotFoundPlugin = require('react-dev-utils/ModuleNotFoundPlugin'); const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin-alt'); +const modules = require('./modules'); const typescriptFormatter = require('react-dev-utils/typescriptFormatter'); // @remove-on-eject-begin const getCacheIdentifier = require('react-dev-utils/getCacheIdentifier'); @@ -259,8 +260,7 @@ module.exports = function(webpackEnv) { // if there are any conflicts. This matches Node resolution mechanism. // https://github.com/facebook/create-react-app/issues/253 modules: ['node_modules'].concat( - // It is guaranteed to exist because we tweak it in `env.js` - process.env.NODE_PATH.split(path.delimiter).filter(Boolean) + modules.additionalModulePath ? [modules.additionalModulePath] : [] ), // These are the reasonable defaults supported by the Node ecosystem. // We also include JSX as a common component filename extension to support @@ -275,6 +275,7 @@ module.exports = function(webpackEnv) { // Support React Native Web // https://www.smashingmagazine.com/2016/08/a-glimpse-into-the-future-with-react-native-for-web/ 'react-native': 'react-native-web', + ...modules.webpackAliases, }, plugins: [ // Adds support for installing with Plug'n'Play, leading to faster installs and adding diff --git a/packages/react-scripts/fixtures/kitchensink/integration/config.test.js b/packages/react-scripts/fixtures/kitchensink/integration/config.test.js new file mode 100644 index 00000000000..07831724aa4 --- /dev/null +++ b/packages/react-scripts/fixtures/kitchensink/integration/config.test.js @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import initDOM from './initDOM'; + +describe('Integration', () => { + describe('jsconfig.json/tsconfig.json', () => { + it('Supports setting baseUrl to src', async () => { + const doc = await initDOM('base-url'); + + expect(doc.getElementById('feature-base-url').childElementCount).toBe(4); + doc.defaultView.close(); + }); + + it('Supports setting @ as alias to src', async () => { + const doc = await initDOM('alias'); + + expect(doc.getElementById('feature-alias').childElementCount).toBe(4); + doc.defaultView.close(); + }); + }); +}); diff --git a/packages/react-scripts/fixtures/kitchensink/integration/env.test.js b/packages/react-scripts/fixtures/kitchensink/integration/env.test.js index 79de16706dc..90e0e631e88 100644 --- a/packages/react-scripts/fixtures/kitchensink/integration/env.test.js +++ b/packages/react-scripts/fixtures/kitchensink/integration/env.test.js @@ -37,13 +37,6 @@ describe('Integration', () => { doc.defaultView.close(); }); - it('NODE_PATH', async () => { - const doc = await initDOM('node-path'); - - expect(doc.getElementById('feature-node-path').childElementCount).toBe(4); - doc.defaultView.close(); - }); - it('PUBLIC_URL', async () => { const doc = await initDOM('public-url'); diff --git a/packages/react-scripts/fixtures/kitchensink/jsconfig.json b/packages/react-scripts/fixtures/kitchensink/jsconfig.json new file mode 100644 index 00000000000..344028e7fc7 --- /dev/null +++ b/packages/react-scripts/fixtures/kitchensink/jsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "baseUrl": "src", + "paths": { + "@*": ["*"] + } + } +} diff --git a/packages/react-scripts/fixtures/kitchensink/src/App.js b/packages/react-scripts/fixtures/kitchensink/src/App.js index 380a49fc639..82fe9da3779 100644 --- a/packages/react-scripts/fixtures/kitchensink/src/App.js +++ b/packages/react-scripts/fixtures/kitchensink/src/App.js @@ -149,8 +149,13 @@ class App extends Component { this.setFeature(f.default) ); break; - case 'node-path': - import('./features/env/NodePath').then(f => this.setFeature(f.default)); + case 'base-url': + import('./features/config/BaseUrl').then(f => + this.setFeature(f.default) + ); + break; + case 'alias': + import('./features/config/Alias').then(f => this.setFeature(f.default)); break; case 'no-ext-inclusion': import('./features/webpack/NoExtInclusion').then(f => diff --git a/packages/react-scripts/fixtures/kitchensink/src/features/config/Alias.js b/packages/react-scripts/fixtures/kitchensink/src/features/config/Alias.js new file mode 100644 index 00000000000..f803de8ef6c --- /dev/null +++ b/packages/react-scripts/fixtures/kitchensink/src/features/config/Alias.js @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import load from '@/absoluteLoad'; + +export default class extends Component { + static propTypes = { + onReady: PropTypes.func.isRequired, + }; + + constructor(props) { + super(props); + this.state = { users: [] }; + } + + async componentDidMount() { + const users = load(); + this.setState({ users }); + } + + componentDidUpdate() { + this.props.onReady(); + } + + render() { + return ( +
+ {this.state.users.map(user => ( +
{user.name}
+ ))} +
+ ); + } +} diff --git a/packages/react-scripts/fixtures/kitchensink/src/features/env/NodePath.test.js b/packages/react-scripts/fixtures/kitchensink/src/features/config/Alias.test.js similarity index 75% rename from packages/react-scripts/fixtures/kitchensink/src/features/env/NodePath.test.js rename to packages/react-scripts/fixtures/kitchensink/src/features/config/Alias.test.js index 1de025d2f2f..94288459b25 100644 --- a/packages/react-scripts/fixtures/kitchensink/src/features/env/NodePath.test.js +++ b/packages/react-scripts/fixtures/kitchensink/src/features/config/Alias.test.js @@ -7,13 +7,13 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import NodePath from './NodePath'; +import Alias from './Alias'; -describe('NODE_PATH', () => { +describe('alias', () => { it('renders without crashing', () => { const div = document.createElement('div'); return new Promise(resolve => { - ReactDOM.render(, div); + ReactDOM.render(, div); }); }); }); diff --git a/packages/react-scripts/fixtures/kitchensink/src/features/env/NodePath.js b/packages/react-scripts/fixtures/kitchensink/src/features/config/BaseUrl.js similarity index 95% rename from packages/react-scripts/fixtures/kitchensink/src/features/env/NodePath.js rename to packages/react-scripts/fixtures/kitchensink/src/features/config/BaseUrl.js index e89228e20b0..818d4db271b 100644 --- a/packages/react-scripts/fixtures/kitchensink/src/features/env/NodePath.js +++ b/packages/react-scripts/fixtures/kitchensink/src/features/config/BaseUrl.js @@ -30,7 +30,7 @@ export default class extends Component { render() { return ( -
+
{this.state.users.map(user => (
{user.name}
))} diff --git a/packages/react-scripts/fixtures/kitchensink/src/features/config/BaseUrl.test.js b/packages/react-scripts/fixtures/kitchensink/src/features/config/BaseUrl.test.js new file mode 100644 index 00000000000..378ec9d2fa9 --- /dev/null +++ b/packages/react-scripts/fixtures/kitchensink/src/features/config/BaseUrl.test.js @@ -0,0 +1,19 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import BaseUrl from './BaseUrl'; + +describe('baseUrl', () => { + it('renders without crashing', () => { + const div = document.createElement('div'); + return new Promise(resolve => { + ReactDOM.render(, div); + }); + }); +}); diff --git a/packages/react-scripts/scripts/utils/createJestConfig.js b/packages/react-scripts/scripts/utils/createJestConfig.js index 58c2ad48812..cf4e86495cd 100644 --- a/packages/react-scripts/scripts/utils/createJestConfig.js +++ b/packages/react-scripts/scripts/utils/createJestConfig.js @@ -10,6 +10,7 @@ const fs = require('fs'); const chalk = require('chalk'); const paths = require('../../config/paths'); +const modules = require('../../config/modules'); module.exports = (resolve, rootDir, isEjecting) => { // Use this instead of `paths.testsSetup` to avoid putting @@ -57,9 +58,13 @@ module.exports = (resolve, rootDir, isEjecting) => { '[/\\\\]node_modules[/\\\\].+\\.(js|jsx|ts|tsx)$', '^.+\\.module\\.(css|sass|scss)$', ], + modulePaths: modules.additionalModulePath + ? [modules.additionalModulePath] + : [], moduleNameMapper: { '^react-native$': 'react-native-web', '^.+\\.module\\.(css|sass|scss)$': 'identity-obj-proxy', + ...modules.jestAliases, }, moduleFileExtensions: [...paths.moduleFileExtensions, 'node'].filter( ext => !ext.includes('mjs') diff --git a/packages/react-scripts/scripts/utils/verifyTypeScriptSetup.js b/packages/react-scripts/scripts/utils/verifyTypeScriptSetup.js index d8da57a3509..7467a7016c6 100644 --- a/packages/react-scripts/scripts/utils/verifyTypeScriptSetup.js +++ b/packages/react-scripts/scripts/utils/verifyTypeScriptSetup.js @@ -121,13 +121,6 @@ function verifyTypeScriptSetup() { value: 'preserve', reason: 'JSX is compiled by Babel', }, - // We do not support absolute imports, though this may come as a future - // enhancement - baseUrl: { - value: undefined, - reason: 'absolute imports are not supported (yet)', - }, - paths: { value: undefined, reason: 'aliased imports are not supported' }, }; const formatDiagnosticHost = { diff --git a/tasks/e2e-kitchensink.sh b/tasks/e2e-kitchensink.sh index 2b886a1791a..e38da60c8df 100755 --- a/tasks/e2e-kitchensink.sh +++ b/tasks/e2e-kitchensink.sh @@ -122,7 +122,6 @@ npm link "$temp_module_path/node_modules/test-integrity" # Test the build REACT_APP_SHELL_ENV_MESSAGE=fromtheshell \ - NODE_PATH=src \ PUBLIC_URL=http://www.example.org/spa/ \ yarn build @@ -134,7 +133,6 @@ exists build/static/js/main.*.js # https://facebook.github.io/jest/docs/en/troubleshooting.html#tests-are-extremely-slow-on-docker-and-or-continuous-integration-ci-server REACT_APP_SHELL_ENV_MESSAGE=fromtheshell \ CI=true \ - NODE_PATH=src \ NODE_ENV=test \ yarn test --no-cache --runInBand --testPathPattern=src @@ -142,21 +140,19 @@ REACT_APP_SHELL_ENV_MESSAGE=fromtheshell \ tmp_server_log=`mktemp` PORT=3001 \ REACT_APP_SHELL_ENV_MESSAGE=fromtheshell \ - NODE_PATH=src \ nohup yarn start &>$tmp_server_log & grep -q 'You can now view' <(tail -f $tmp_server_log) # Test "development" environment E2E_URL="http://localhost:3001" \ REACT_APP_SHELL_ENV_MESSAGE=fromtheshell \ - CI=true NODE_PATH=src \ + CI=true \ NODE_ENV=development \ BABEL_ENV=test \ node_modules/.bin/jest --no-cache --runInBand --config='jest.integration.config.js' # Test "production" environment E2E_FILE=./build/index.html \ CI=true \ - NODE_PATH=src \ NODE_ENV=production \ BABEL_ENV=test \ PUBLIC_URL=http://www.example.org/spa/ \ diff --git a/test/fixtures/typescript/src/App.test.ts b/test/fixtures/typescript/src/App.test.ts index f02c462b553..41958542474 100644 --- a/test/fixtures/typescript/src/App.test.ts +++ b/test/fixtures/typescript/src/App.test.ts @@ -13,3 +13,23 @@ it('supports decorators', () => { const app = new App(); expect(app.decorated).toBe(42); }); + +it('supports loading modules with baseUrl', () => { + const app = new App(); + expect(app.users).toEqual([ + { id: 1, name: '1' }, + { id: 2, name: '2' }, + { id: 3, name: '3' }, + { id: 4, name: '4' }, + ]); +}); + +it('supports loading modules with alias', () => { + const app = new App(); + expect(app.usersWithAlias).toEqual([ + { id: 1, name: '1' }, + { id: 2, name: '2' }, + { id: 3, name: '3' }, + { id: 4, name: '4' }, + ]); +}); diff --git a/test/fixtures/typescript/src/App.ts b/test/fixtures/typescript/src/App.ts index d803c92d199..f04166f9453 100644 --- a/test/fixtures/typescript/src/App.ts +++ b/test/fixtures/typescript/src/App.ts @@ -1,3 +1,6 @@ +import absoluteLoad from 'absoluteLoad'; +import absoluteLoadWithAlias from '@/absoluteLoad'; + interface MyType { foo: number; bar: boolean; @@ -12,6 +15,8 @@ class App { n = App.foo.baz!.n; @propertyDecorator decorated = 5; + users = absoluteLoad(); + usersWithAlias = absoluteLoadWithAlias(); } function annotation(target: any) { diff --git a/test/fixtures/typescript/src/absoluteLoad.ts b/test/fixtures/typescript/src/absoluteLoad.ts new file mode 100644 index 00000000000..5c4f7842e28 --- /dev/null +++ b/test/fixtures/typescript/src/absoluteLoad.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +export default () => [ + { id: 1, name: '1' }, + { id: 2, name: '2' }, + { id: 3, name: '3' }, + { id: 4, name: '4' }, +]; diff --git a/test/fixtures/typescript/tsconfig.json b/test/fixtures/typescript/tsconfig.json index 504cd646e14..7ce41dc24cb 100644 --- a/test/fixtures/typescript/tsconfig.json +++ b/test/fixtures/typescript/tsconfig.json @@ -1,5 +1,9 @@ { "compilerOptions": { + "baseUrl": "src", + "paths": { + "@*": ["*"] + }, "experimentalDecorators": true } }