Repository: jsx-eslint/eslint-plugin-react
Pull request #3771 (2024-06-20 22:59:51)

[New] version settings: Allow react defaultVersion to be configurable
I have some projects where I want to detect the react version most of the time, but sometimes I expect that to fail. In those cases, I don't want to default to "latest". I instead want to choose the default fallback version of React.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b6aed61a..b4e0c647 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,7 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange
 * add [`jsx-props-no-spread-multi`] ([#3724][] @SimonSchick)
 * [`forbid-component-props`]: add `propNamePattern` to allow / disallow prop name patterns ([#3774][] @akulsr0)
 * [`jsx-handler-names`]: support ignoring component names ([#3772][] @akulsr0)
+* version settings: Allow react defaultVersion to be configurable ([#3771][] @onlywei)
 
 ### Changed
 * [Refactor] `variableUtil`: Avoid creating a single flat variable scope for each lookup ([#3782][] @DanielRosenwasser)
@@ -18,6 +19,7 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange
 [#3782]: https://github.com/jsx-eslint/eslint-plugin-react/pull/3782
 [#3774]: https://github.com/jsx-eslint/eslint-plugin-react/pull/3774
 [#3772]: https://github.com/jsx-eslint/eslint-plugin-react/pull/3772
+[#3771]: https://github.com/jsx-eslint/eslint-plugin-react/pull/3771
 [#3759]: https://github.com/jsx-eslint/eslint-plugin-react/pull/3759
 [#3724]: https://github.com/jsx-eslint/eslint-plugin-react/pull/3724
 [#3694]: https://github.com/jsx-eslint/eslint-plugin-react/pull/3694
diff --git a/README.md b/README.md
index 693ad667..e77a809e 100644
--- a/README.md
+++ b/README.md
@@ -42,7 +42,9 @@ You should also specify settings that will be shared across all the plugin rules
       "fragment": "Fragment",  // Fragment to use (may be a property of <pragma>), default to "Fragment"
       "version": "detect", // React version. "detect" automatically picks the version you have installed.
                            // You can also use `16.0`, `16.3`, etc, if you want to override the detected value.
-                           // It will default to "latest" and warn if missing, and to "detect" in the future
+                           // Defaults to the "defaultVersion" setting and warns if missing, and to "detect" in the future
+      "defaultVersion": "", // Default React version to use when the version you have installed cannot be detected.
+                            // If not provided, defaults to the latest React version.
       "flowVersion": "0.53" // Flow version
     },
     "propWrapperFunctions": [
diff --git a/lib/util/version.js b/lib/util/version.js
index cd8490c0..9db44dbe 100644
--- a/lib/util/version.js
+++ b/lib/util/version.js
@@ -12,6 +12,8 @@ const resolve = require('resolve');
 const semver = require('semver');
 const error = require('./error');
 
+const ULTIMATE_LATEST_SEMVER = '999.999.999';
+
 let warnedForMissingVersion = false;
 
 function resetWarningFlag() {
@@ -44,6 +46,37 @@ function resolveBasedir(contextOrFilename) {
   return process.cwd();
 }
 
+function convertConfVerToSemver(confVer) {
+  const fullSemverString = /^[0-9]+\.[0-9]+$/.test(confVer) ? `${confVer}.0` : confVer;
+  return semver.coerce(fullSemverString.split('.').map((part) => Number(part)).join('.'));
+}
+
+let defaultVersion = ULTIMATE_LATEST_SEMVER;
+
+function resetDefaultVersion() {
+  defaultVersion = ULTIMATE_LATEST_SEMVER;
+}
+
+function readDefaultReactVersionFromContext(context) {
+  // .eslintrc shared settings (https://eslint.org/docs/user-guide/configuring#adding-shared-settings)
+  if (context.settings && context.settings.react && context.settings.react.defaultVersion) {
+    let settingsDefaultVersion = context.settings.react.defaultVersion;
+    if (typeof settingsDefaultVersion !== 'string') {
+      error('Warning: default React version specified in eslint-pluigin-react-settings must be a string; '
+        + `got "${typeof settingsDefaultVersion}"`);
+    }
+    settingsDefaultVersion = String(settingsDefaultVersion);
+    const result = convertConfVerToSemver(settingsDefaultVersion);
+    if (result) {
+      defaultVersion = result.version;
+    } else {
+      error(`Warning: React version specified in eslint-plugin-react-settings must be a valid semver version, or "detect"; got “${settingsDefaultVersion}”. Falling back to latest version as default.`);
+    }
+  } else {
+    defaultVersion = ULTIMATE_LATEST_SEMVER;
+  }
+}
+
 // TODO, semver-major: remove context fallback
 function detectReactVersion(context) {
   if (cachedDetectedReactVersion) {
@@ -60,20 +93,22 @@ function detectReactVersion(context) {
   } catch (e) {
     if (e.code === 'MODULE_NOT_FOUND') {
       if (!warnedForMissingVersion) {
-        error('Warning: React version was set to "detect" in eslint-plugin-react settings, '
-        + 'but the "react" package is not installed. Assuming latest React version for linting.');
+        let sentence2 = 'Assuming latest React version for linting.';
+        if (defaultVersion !== ULTIMATE_LATEST_SEMVER) {
+          sentence2 = `Assuming default React version for linting: "${defaultVersion}".`;
+        }
+        error(`Warning: React version was set to "detect" in eslint-plugin-react settings, but the "react" package is not installed. ${sentence2}`);
         warnedForMissingVersion = true;
       }
-      cachedDetectedReactVersion = '999.999.999';
+      cachedDetectedReactVersion = defaultVersion;
       return cachedDetectedReactVersion;
     }
     throw e;
   }
 }
 
-const defaultVersion = '999.999.999';
-
 function getReactVersionFromContext(context) {
+  readDefaultReactVersionFromContext(context);
   let confVer = defaultVersion;
   // .eslintrc shared settings (https://eslint.org/docs/user-guide/configuring#adding-shared-settings)
   if (context.settings && context.settings.react && context.settings.react.version) {
@@ -91,8 +126,8 @@ function getReactVersionFromContext(context) {
       + 'See https://github.com/jsx-eslint/eslint-plugin-react#configuration .');
     warnedForMissingVersion = true;
   }
-  confVer = /^[0-9]+\.[0-9]+$/.test(confVer) ? `${confVer}.0` : confVer;
-  const result = semver.coerce(confVer.split('.').map((part) => Number(part)).join('.'));
+
+  const result = convertConfVerToSemver(confVer);
   if (!result) {
     error(`Warning: React version specified in eslint-plugin-react-settings must be a valid semver version, or "detect"; got “${confVer}”`);
   }
@@ -111,7 +146,7 @@ function detectFlowVersion(context) {
     if (e.code === 'MODULE_NOT_FOUND') {
       error('Warning: Flow version was set to "detect" in eslint-plugin-react settings, '
         + 'but the "flow-bin" package is not installed. Assuming latest Flow version for linting.');
-      return '999.999.999';
+      return ULTIMATE_LATEST_SEMVER;
     }
     throw e;
   }
@@ -133,8 +168,8 @@ function getFlowVersionFromContext(context) {
   } else {
     throw 'Could not retrieve flowVersion from settings'; // eslint-disable-line no-throw-literal
   }
-  confVer = /^[0-9]+\.[0-9]+$/.test(confVer) ? `${confVer}.0` : confVer;
-  const result = semver.coerce(confVer.split('.').map((part) => Number(part)).join('.'));
+
+  const result = convertConfVerToSemver(confVer);
   if (!result) {
     error(`Warning: Flow version specified in eslint-plugin-react-settings must be a valid semver version, or "detect"; got “${confVer}”`);
   }
@@ -158,4 +193,5 @@ module.exports = {
   testFlowVersion,
   resetWarningFlag,
   resetDetectedVersion,
+  resetDefaultVersion,
 };


Test patch:
diff --git a/tests/util/version.js b/tests/util/version.js
index ad9bd458..3ff23e02 100644
--- a/tests/util/version.js
+++ b/tests/util/version.js
@@ -14,6 +14,7 @@ describe('Version', () => {
     expectedErrorArgs = [];
     versionUtil.resetWarningFlag();
     versionUtil.resetDetectedVersion();
+    versionUtil.resetDefaultVersion();
   });
 
   afterEach(() => {
@@ -65,6 +66,33 @@ describe('Version', () => {
       ];
     });
 
+    it('uses default version from settings if provided and react is not installed', () => {
+      context.settings.react.defaultVersion = '16.14.0';
+      sinon.stub(context, 'getFilename').callsFake(() => path.resolve(base, 'detect-version-missing', 'test.js'));
+
+      assert.equal(versionUtil.testReactVersion(context, '16.14.0'), true);
+
+      expectedErrorArgs = [
+        ['Warning: React version was set to "detect" in eslint-plugin-react settings, but the "react" package is not installed. Assuming default React version for linting: "16.14.0".'],
+      ];
+
+      delete context.settings.react.defaultVersion;
+    });
+
+    it('fails nicely with an invalid default version of react', () => {
+      context.settings.react.defaultVersion = 'not semver';
+      sinon.stub(context, 'getFilename').callsFake(() => path.resolve(base, 'detect-version-missing', 'test.js'));
+
+      assert.equal(versionUtil.testReactVersion(context, '999.999.999'), true);
+
+      expectedErrorArgs = [
+        ['Warning: React version specified in eslint-plugin-react-settings must be a valid semver version, or "detect"; got “not semver”. Falling back to latest version as default.'],
+        ['Warning: React version was set to "detect" in eslint-plugin-react settings, but the "react" package is not installed. Assuming latest React version for linting.'],
+      ];
+
+      delete context.settings.react.defaultVersion;
+    });
+
     it('warns only once for failure to detect react ', () => {
       sinon.stub(context, 'getFilename').callsFake(() => path.resolve(base, 'detect-version-missing', 'test.js'));