Repository: jsx-eslint/eslint-plugin-react (js)

Issue:
[Bug]: flat config crash when using manual plugin def and recommended config
### Is there an existing issue for this?

- [X] I have searched the existing issues and my issue is unique
- [X] My issue appears in the command-line and not only in the text editor

### Description Overview

# Repro

```js
// eslint.config.js
import eslintReact from 'eslint-plugin-react';
import eslintReactRecommended from 'eslint-plugin-react/configs/recommended.js';

export default [
	{
		plugins: {
			react: eslintReact,
		},
	},
	eslintReactRecommended,
];
```

`npx eslint eslint.config.js`

# Expected

No errors

# Actual

```
Oops! Something went wrong! :(

ESLint: 8.56.0

Error: Key "plugins": Cannot redefine plugin "react".
```

# More Info

When defining a plugin key in the flat config ESLint enforces that you only ever redefine they key with the same plugin. [This check is done by comparing by reference](https://github.com/eslint/eslint/blob/8e13a6beb587e624cc95ae16eefe503ad024b11b/lib/config/flat-config-schema.js#L361-L363) -- i.e. `configA.plugin.react === configB.plugin.react`

`eslint-plugin-react` fails this check because it defines its "plugin" in two locations:
https://github.com/jsx-eslint/eslint-plugin-react/blob/36e791de784e7fa1edd5b6d232fbcaf2dd7cf9bf/index.js#L14-L31
https://github.com/jsx-eslint/eslint-plugin-react/blob/36e791de784e7fa1edd5b6d232fbcaf2dd7cf9bf/configs/all.js#L23-L26

For this to work there needs to be exactly 1 copy of the "plugin" defined by the package.
For example:
```js
const plugin = { ... };
plugin.configs = {
  recommended: {
    plugins: { react: plugin },
    rules: { ... },
  },
  all: {
    plugins: { react: plugin },
    rules: { ... },
  },
});
```

### eslint-plugin-react version

v7.33.2

### eslint version

v8.56.0

### node version

v20.11.0

Pull request:
[New] export flat configs from plugin root and fix flat config crash
fixes #3693

This PR exposes the flat configs at the root of the plugin:
```js
const reactPlugin = require('eslint-plugin-react');

module.exports = [
  // ...
  reactPlugin.configs['flat/recommended'],
  // ...
];
```

The `flat/` prefix is an approach a number of plugins are taking to enable supporting both styles at the top-level.

---

This was the easiest way to set things up so that the plugin reference was consistent across the root and all configs.

I'm open to a different approach to fixing the issue - for example if you want to ensure the `config/` exports all work as well - it's just going to be a much larger refactor to make that work because we need to restructure the configs so there isn't a cyclic dependency between the configs and the plugin.

*Personally* I'm of the opinion that this "everything from the root" approach offers better DevX compared to forcing users to separately import the configs. It's a stylistic thing though - up to you really.

Patch:
diff --git a/.eslint-doc-generatorrc.js b/.eslint-doc-generatorrc.js
index 1e6d1717..81c443be 100644
--- a/.eslint-doc-generatorrc.js
+++ b/.eslint-doc-generatorrc.js
@@ -4,7 +4,7 @@ const config = {
     ['jsx-runtime', '🏃'],
     ['recommended', '☑️'],
   ],
-  ignoreConfig: ['all'],
+  ignoreConfig: ['all', 'flat'],
   urlConfigs: 'https://github.com/jsx-eslint/eslint-plugin-react/#shareable-configs',
 };
 
diff --git a/.eslintrc b/.eslintrc
index e609aa23..b77f8a94 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -16,6 +16,7 @@
 	"ignorePatterns": [
 		"coverage/",
 		".nyc_output/",
+		"tests/fixtures/flat-config/"
 	],
 	"rules": {
 		"comma-dangle": [2, "always-multiline"],
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d64f8ed1..2c62eed2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,12 +5,17 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange
 
 ## Unreleased
 
+### Added
+* export flat configs from plugin root and fix flat config crash ([#3694][] @bradzacher @mdjermanovic)
+
+[#3694]: https://github.com/jsx-eslint/eslint-plugin-react/pull/3694
+
 ## [7.34.4] - 2024.07.13
 
 ### Fixed
 
-* [`prop-types`]: fix `className` missing in prop validation false negative ([#3749] @akulsr0)
-* [`sort-prop-types`]: Check for undefined before accessing `node.typeAnnotation.typeAnnotation` ([#3779] @tylerlaprade)
+* [`prop-types`]: fix `className` missing in prop validation false negative ([#3749][] @akulsr0)
+* [`sort-prop-types`]: Check for undefined before accessing `node.typeAnnotation.typeAnnotation` ([#3779][] @tylerlaprade)
 
 [7.34.4]: https://github.com/jsx-eslint/eslint-plugin-react/compare/v7.34.3...v7.34.4
 [#3779]: https://github.com/jsx-eslint/eslint-plugin-react/pull/3779
diff --git a/README.md b/README.md
index 64e57912..55281520 100644
--- a/README.md
+++ b/README.md
@@ -203,27 +203,22 @@ Refer to the [official docs](https://eslint.org/docs/latest/user-guide/configuri
 The schema of the `settings.react` object would be identical to that of what's already described above in the legacy config section.
 
 <!-- markdownlint-disable-next-line no-duplicate-heading -->
-### Shareable configs
-
-There're also 3 shareable configs.
-
-- `eslint-plugin-react/configs/all`
-- `eslint-plugin-react/configs/recommended`
-- `eslint-plugin-react/configs/jsx-runtime`
+### Flat Configs
 
-If your eslint.config.js is ESM, include the `.js` extension (e.g. `eslint-plugin-react/recommended.js`). Note that the next semver-major will require omitting the extension for these imports.
+This plugin exports 3 flat configs:
 
-**Note**: These configurations will import `eslint-plugin-react` and enable JSX in [`languageOptions.parserOptions`](https://eslint.org/docs/latest/user-guide/configuring/configuration-files-new#configuration-objects).
+- `flat.all`
+- `flat.recommended`
+- `flat['jsx-runtime']`
 
-In the new config system, `plugin:` protocol(e.g. `plugin:react/recommended`) is no longer valid.
-As eslint does not automatically import the preset config (shareable config), you explicitly do it by yourself.
+The flat configs are available via the root plugin import. They will configure the plugin under the `react/` namespace and enable JSX in [`languageOptions.parserOptions`](https://eslint.org/docs/latest/use/configure/language-options#specifying-parser-options).
 
 ```js
-const reactRecommended = require('eslint-plugin-react/configs/recommended');
+const reactPlugin = require('eslint-plugin-react');
 
 module.exports = [
   …
-  reactRecommended, // This is not a plugin object, but a shareable config object
+  reactPlugin.configs.flat.recommended, // This is not a plugin object, but a shareable config object
   …
 ];
 ```
@@ -234,16 +229,16 @@ You can of course add/override some properties.
 For most of the cases, you probably want to configure some properties by yourself.
 
 ```js
-const reactRecommended = require('eslint-plugin-react/configs/recommended');
+const reactPlugin = require('eslint-plugin-react');
 const globals = require('globals');
 
 module.exports = [
   …
   {
     files: ['**/*.{js,mjs,cjs,jsx,mjsx,ts,tsx,mtsx}'],
-    ...reactRecommended,
+    ...reactPlugin.configs.flat.recommended,
     languageOptions: {
-      ...reactRecommended.languageOptions,
+      ...reactPlugin.configs.flat.recommended.languageOptions,
       globals: {
         ...globals.serviceworker,
         ...globals.browser,
@@ -257,14 +252,14 @@ module.exports = [
 The above example is same as the example below, as the new config system is based on chaining.
 
 ```js
-const reactRecommended = require('eslint-plugin-react/configs/recommended');
+const reactPlugin = require('eslint-plugin-react');
 const globals = require('globals');
 
 module.exports = [
   …
   {
     files: ['**/*.{js,mjs,cjs,jsx,mjsx,ts,tsx,mtsx}'],
-    ...reactRecommended,
+    ...reactPlugin.configs.flat.recommended,
   },
   {
     files: ['**/*.{js,mjs,cjs,jsx,mjsx,ts,tsx,mtsx}'],
diff --git a/configs/all.js b/configs/all.js
index bcb416e2..3123c5bd 100644
--- a/configs/all.js
+++ b/configs/all.js
@@ -1,49 +1,13 @@
 'use strict';
 
-const fromEntries = require('object.fromentries');
-const entries = require('object.entries');
+const plugin = require('..');
 
-const allRules = require('../lib/rules');
-
-function filterRules(rules, predicate) {
-  return fromEntries(entries(rules).filter((entry) => predicate(entry[1])));
-}
-
-/**
- * @param {object} rules - rules object mapping rule name to rule module
- * @returns {Record<string, 2>}
- */
-function configureAsError(rules) {
-  return fromEntries(Object.keys(rules).map((key) => [`react/${key}`, 2]));
-}
-
-const activeRules = filterRules(allRules, (rule) => !rule.meta.deprecated);
-const activeRulesConfig = configureAsError(activeRules);
-
-const deprecatedRules = filterRules(allRules, (rule) => rule.meta.deprecated);
+const legacyConfig = plugin.configs.all;
 
 module.exports = {
-  plugins: {
-    /**
-     * @type {{
-     *   deprecatedRules: Record<string, import('eslint').Rule.RuleModule>,
-     *   rules: Record<string, import('eslint').Rule.RuleModule>,
-     * }}
-     */
-    react: {
-      deprecatedRules,
-      rules: allRules,
-    },
-  },
-  rules: activeRulesConfig,
-  languageOptions: {
-    parserOptions: {
-      ecmaFeatures: {
-        jsx: true,
-      },
-    },
-  },
+  plugins: { react: plugin },
+  rules: legacyConfig.rules,
+  languageOptions: { parserOptions: legacyConfig.parserOptions },
 };
 
-// this is so the `languageOptions` property won't be warned in the new config system
 Object.defineProperty(module.exports, 'languageOptions', { enumerable: false });
diff --git a/configs/jsx-runtime.js b/configs/jsx-runtime.js
index a6302be5..f5cd1785 100644
--- a/configs/jsx-runtime.js
+++ b/configs/jsx-runtime.js
@@ -1,18 +1,13 @@
 'use strict';
 
-const all = require('./all');
+const plugin = require('..');
 
-module.exports = Object.assign({}, all, {
-  languageOptions: Object.assign({}, all.languageOptions, {
-    parserOptions: Object.assign({}, all.languageOptions.parserOptions, {
-      jsxPragma: null, // for @typescript/eslint-parser
-    }),
-  }),
-  rules: {
-    'react/react-in-jsx-scope': 0,
-    'react/jsx-uses-react': 0,
-  },
-});
+const legacyConfig = plugin.configs['jsx-runtime'];
+
+module.exports = {
+  plugins: { react: plugin },
+  rules: legacyConfig.rules,
+  languageOptions: { parserOptions: legacyConfig.parserOptions },
+};
 
-// this is so the `languageOptions` property won't be warned in the new config system
 Object.defineProperty(module.exports, 'languageOptions', { enumerable: false });
diff --git a/configs/recommended.js b/configs/recommended.js
index 0bba09f7..19cbda4c 100644
--- a/configs/recommended.js
+++ b/configs/recommended.js
@@ -1,34 +1,13 @@
 'use strict';
 
-const all = require('./all');
+const plugin = require('..');
 
-module.exports = Object.assign({}, all, {
-  languageOptions: all.languageOptions,
-  rules: {
-    'react/display-name': 2,
-    'react/jsx-key': 2,
-    'react/jsx-no-comment-textnodes': 2,
-    'react/jsx-no-duplicate-props': 2,
-    'react/jsx-no-target-blank': 2,
-    'react/jsx-no-undef': 2,
-    'react/jsx-uses-react': 2,
-    'react/jsx-uses-vars': 2,
-    'react/no-children-prop': 2,
-    'react/no-danger-with-children': 2,
-    'react/no-deprecated': 2,
-    'react/no-direct-mutation-state': 2,
-    'react/no-find-dom-node': 2,
-    'react/no-is-mounted': 2,
-    'react/no-render-return-value': 2,
-    'react/no-string-refs': 2,
-    'react/no-unescaped-entities': 2,
-    'react/no-unknown-property': 2,
-    'react/no-unsafe': 0,
-    'react/prop-types': 2,
-    'react/react-in-jsx-scope': 2,
-    'react/require-render-return': 2,
-  },
-});
+const legacyConfig = plugin.configs.recommended;
+
+module.exports = {
+  plugins: { react: plugin },
+  rules: legacyConfig.rules,
+  languageOptions: { parserOptions: legacyConfig.parserOptions },
+};
 
-// this is so the `languageOptions` property won't be warned in the new config system
 Object.defineProperty(module.exports, 'languageOptions', { enumerable: false });
diff --git a/index.js b/index.js
index 4140c6c8..d1b2c120 100644
--- a/index.js
+++ b/index.js
@@ -1,31 +1,109 @@
 'use strict';
 
-const configAll = require('./configs/all');
-const configRecommended = require('./configs/recommended');
-const configRuntime = require('./configs/jsx-runtime');
+const fromEntries = require('object.fromentries');
+const entries = require('object.entries');
 
 const allRules = require('./lib/rules');
 
+function filterRules(rules, predicate) {
+  return fromEntries(entries(rules).filter((entry) => predicate(entry[1])));
+}
+
+/**
+ * @param {object} rules - rules object mapping rule name to rule module
+ * @returns {Record<string, 2>}
+ */
+function configureAsError(rules) {
+  return fromEntries(Object.keys(rules).map((key) => [`react/${key}`, 2]));
+}
+
+const activeRules = filterRules(allRules, (rule) => !rule.meta.deprecated);
+const activeRulesConfig = configureAsError(activeRules);
+
+const deprecatedRules = filterRules(allRules, (rule) => rule.meta.deprecated);
+
 // for legacy config system
 const plugins = [
   'react',
 ];
 
-module.exports = {
-  deprecatedRules: configAll.plugins.react.deprecatedRules,
+const plugin = {
+  deprecatedRules,
   rules: allRules,
   configs: {
-    recommended: Object.assign({}, configRecommended, {
-      parserOptions: configRecommended.languageOptions.parserOptions,
+    recommended: {
       plugins,
-    }),
-    all: Object.assign({}, configAll, {
-      parserOptions: configAll.languageOptions.parserOptions,
+      parserOptions: {
+        ecmaFeatures: {
+          jsx: true,
+        },
+      },
+      rules: {
+        'react/display-name': 2,
+        'react/jsx-key': 2,
+        'react/jsx-no-comment-textnodes': 2,
+        'react/jsx-no-duplicate-props': 2,
+        'react/jsx-no-target-blank': 2,
+        'react/jsx-no-undef': 2,
+        'react/jsx-uses-react': 2,
+        'react/jsx-uses-vars': 2,
+        'react/no-children-prop': 2,
+        'react/no-danger-with-children': 2,
+        'react/no-deprecated': 2,
+        'react/no-direct-mutation-state': 2,
+        'react/no-find-dom-node': 2,
+        'react/no-is-mounted': 2,
+        'react/no-render-return-value': 2,
+        'react/no-string-refs': 2,
+        'react/no-unescaped-entities': 2,
+        'react/no-unknown-property': 2,
+        'react/no-unsafe': 0,
+        'react/prop-types': 2,
+        'react/react-in-jsx-scope': 2,
+        'react/require-render-return': 2,
+      },
+    },
+    all: {
       plugins,
-    }),
-    'jsx-runtime': Object.assign({}, configRuntime, {
-      parserOptions: configRuntime.languageOptions.parserOptions,
+      parserOptions: {
+        ecmaFeatures: {
+          jsx: true,
+        },
+      },
+      rules: activeRulesConfig,
+    },
+    'jsx-runtime': {
       plugins,
-    }),
+      parserOptions: {
+        ecmaFeatures: {
+          jsx: true,
+        },
+        jsxPragma: null, // for @typescript/eslint-parser
+      },
+      rules: {
+        'react/react-in-jsx-scope': 0,
+        'react/jsx-uses-react': 0,
+      },
+    },
+  },
+};
+
+plugin.configs.flat = {
+  recommended: {
+    plugins: { react: plugin },
+    rules: plugin.configs.recommended.rules,
+    languageOptions: { parserOptions: plugin.configs.recommended.parserOptions },
+  },
+  all: {
+    plugins: { react: plugin },
+    rules: plugin.configs.all.rules,
+    languageOptions: { parserOptions: plugin.configs.all.parserOptions },
+  },
+  'jsx-runtime': {
+    plugins: { react: plugin },
+    rules: plugin.configs['jsx-runtime'].rules,
+    languageOptions: { parserOptions: plugin.configs['jsx-runtime'].parserOptions },
   },
 };
+
+module.exports = plugin;
diff --git a/lib/rules/index.js b/lib/rules/index.js
index 784831bb..c30dc6e6 100644
--- a/lib/rules/index.js
+++ b/lib/rules/index.js
@@ -2,7 +2,7 @@
 
 /* eslint global-require: 0 */
 
-/** @type {Record<string, import('eslint').Rule.RuleModule>} */
+/** @satisfies {Record<string, import('eslint').Rule.RuleModule>} */
 module.exports = {
   'boolean-prop-naming': require('./boolean-prop-naming'),
   'button-has-type': require('./button-has-type'),
diff --git a/package.json b/package.json
index bf263179..72de6757 100644
--- a/package.json
+++ b/package.json
@@ -15,7 +15,7 @@
     "test": "npm run unit-test",
     "posttest": "aud --production",
     "type-check": "tsc",
-    "unit-test": "istanbul cover node_modules/mocha/bin/_mocha tests/lib/**/*.js tests/util/**/*.js tests/index.js",
+    "unit-test": "istanbul cover node_modules/mocha/bin/_mocha tests/lib/**/*.js tests/util/**/*.js tests/index.js tests/flat-config.js",
     "update:eslint-docs": "eslint-doc-generator"
   },
   "repository": {


Test patch:
diff --git a/tests/fixtures/flat-config/config-all/eslint.config-deep.js b/tests/fixtures/flat-config/config-all/eslint.config-deep.js
new file mode 100644
index 00000000..e74b950f
--- /dev/null
+++ b/tests/fixtures/flat-config/config-all/eslint.config-deep.js
@@ -0,0 +1,11 @@
+'use strict';
+
+const reactAll = require('../../../../configs/all');
+
+module.exports = [{
+  files: ['**/*.jsx'],
+  ...reactAll,
+  languageOptions: {
+    ...reactAll.languageOptions
+  }
+}];
diff --git a/tests/fixtures/flat-config/config-all/eslint.config-root.js b/tests/fixtures/flat-config/config-all/eslint.config-root.js
new file mode 100644
index 00000000..f5dc21e0
--- /dev/null
+++ b/tests/fixtures/flat-config/config-all/eslint.config-root.js
@@ -0,0 +1,8 @@
+'use strict';
+
+const reactPlugin = require('../../../..');
+
+module.exports = [{
+  files: ['**/*.jsx'],
+  ...reactPlugin.configs.flat.all
+}];
diff --git a/tests/fixtures/flat-config/config-all/test.jsx b/tests/fixtures/flat-config/config-all/test.jsx
new file mode 100644
index 00000000..66e96ef9
--- /dev/null
+++ b/tests/fixtures/flat-config/config-all/test.jsx
@@ -0,0 +1,3 @@
+<div foo="hello">
+    test
+</div>
diff --git a/tests/fixtures/flat-config/config-jsx-runtime/eslint.config-deep.js b/tests/fixtures/flat-config/config-jsx-runtime/eslint.config-deep.js
new file mode 100644
index 00000000..053bf08d
--- /dev/null
+++ b/tests/fixtures/flat-config/config-jsx-runtime/eslint.config-deep.js
@@ -0,0 +1,15 @@
+'use strict';
+
+const reactRecommended = require('../../../../configs/recommended');
+const reactJSXRuntime = require('../../../../configs/jsx-runtime');
+
+module.exports = [
+  {
+    files: ['**/*.jsx'],
+    ...reactRecommended,
+    languageOptions: {
+      ...reactRecommended.languageOptions
+    }
+  },
+  reactJSXRuntime
+];
diff --git a/tests/fixtures/flat-config/config-jsx-runtime/eslint.config-root.js b/tests/fixtures/flat-config/config-jsx-runtime/eslint.config-root.js
new file mode 100644
index 00000000..f6c1c4af
--- /dev/null
+++ b/tests/fixtures/flat-config/config-jsx-runtime/eslint.config-root.js
@@ -0,0 +1,11 @@
+'use strict';
+
+const reactPlugin = require('../../../..');
+
+module.exports = [
+  {
+    files: ['**/*.jsx'],
+    ...reactPlugin.configs.flat.recommended
+  },
+  reactPlugin.configs.flat['jsx-runtime']
+];
diff --git a/tests/fixtures/flat-config/config-jsx-runtime/test.jsx b/tests/fixtures/flat-config/config-jsx-runtime/test.jsx
new file mode 100644
index 00000000..66e96ef9
--- /dev/null
+++ b/tests/fixtures/flat-config/config-jsx-runtime/test.jsx
@@ -0,0 +1,3 @@
+<div foo="hello">
+    test
+</div>
diff --git a/tests/fixtures/flat-config/config-recommended/eslint.config-deep.js b/tests/fixtures/flat-config/config-recommended/eslint.config-deep.js
new file mode 100644
index 00000000..f9471c23
--- /dev/null
+++ b/tests/fixtures/flat-config/config-recommended/eslint.config-deep.js
@@ -0,0 +1,11 @@
+'use strict';
+
+const reactRecommended = require('../../../../configs/recommended');
+
+module.exports = [{
+  files: ['**/*.jsx'],
+  ...reactRecommended,
+  languageOptions: {
+    ...reactRecommended.languageOptions
+  }
+}];
diff --git a/tests/fixtures/flat-config/config-recommended/eslint.config-root.js b/tests/fixtures/flat-config/config-recommended/eslint.config-root.js
new file mode 100644
index 00000000..c1acda02
--- /dev/null
+++ b/tests/fixtures/flat-config/config-recommended/eslint.config-root.js
@@ -0,0 +1,8 @@
+'use strict';
+
+const reactPlugin = require('../../../..');
+
+module.exports = [{
+  files: ['**/*.jsx'],
+  ...reactPlugin.configs.flat.recommended
+}];
diff --git a/tests/fixtures/flat-config/config-recommended/test.jsx b/tests/fixtures/flat-config/config-recommended/test.jsx
new file mode 100644
index 00000000..66e96ef9
--- /dev/null
+++ b/tests/fixtures/flat-config/config-recommended/test.jsx
@@ -0,0 +1,3 @@
+<div foo="hello">
+    test
+</div>
diff --git a/tests/fixtures/flat-config/plugin-and-config/eslint.config-deep.js b/tests/fixtures/flat-config/plugin-and-config/eslint.config-deep.js
new file mode 100644
index 00000000..ea4882da
--- /dev/null
+++ b/tests/fixtures/flat-config/plugin-and-config/eslint.config-deep.js
@@ -0,0 +1,18 @@
+'use strict';
+
+const react = require('../../../..');
+const reactRecommended = require('../../../../configs/recommended');
+
+module.exports = [
+  {
+    files: ['**/*.jsx'],
+    plugins: { react }
+  },
+  {
+    files: ['**/*.jsx'],
+    ...reactRecommended,
+    languageOptions: {
+      ...reactRecommended.languageOptions
+    }
+  }
+];
diff --git a/tests/fixtures/flat-config/plugin-and-config/eslint.config-root.js b/tests/fixtures/flat-config/plugin-and-config/eslint.config-root.js
new file mode 100644
index 00000000..ad174287
--- /dev/null
+++ b/tests/fixtures/flat-config/plugin-and-config/eslint.config-root.js
@@ -0,0 +1,15 @@
+'use strict';
+
+const react = require('../../../..');
+const reactRecommended = require('../../../../configs/recommended');
+
+module.exports = [
+  {
+    files: ['**/*.jsx'],
+    plugins: { react }
+  },
+  {
+    files: ['**/*.jsx'],
+    ...react.configs.flat.recommended
+  }
+];
diff --git a/tests/fixtures/flat-config/plugin-and-config/test.jsx b/tests/fixtures/flat-config/plugin-and-config/test.jsx
new file mode 100644
index 00000000..66e96ef9
--- /dev/null
+++ b/tests/fixtures/flat-config/plugin-and-config/test.jsx
@@ -0,0 +1,3 @@
+<div foo="hello">
+    test
+</div>
diff --git a/tests/fixtures/flat-config/plugin/eslint.config.js b/tests/fixtures/flat-config/plugin/eslint.config.js
new file mode 100644
index 00000000..9945a449
--- /dev/null
+++ b/tests/fixtures/flat-config/plugin/eslint.config.js
@@ -0,0 +1,20 @@
+'use strict';
+
+const react = require('../../../..');
+
+module.exports = [{
+  files: ['**/*.jsx'],
+  languageOptions: {
+    parserOptions: {
+      ecmaFeatures: {
+        jsx: true,
+      },
+    },
+  },
+  plugins: {
+    react,
+  },
+  rules: {
+    'react/jsx-no-literals': 1,
+  },
+}];
diff --git a/tests/fixtures/flat-config/plugin/test.jsx b/tests/fixtures/flat-config/plugin/test.jsx
new file mode 100644
index 00000000..66e96ef9
--- /dev/null
+++ b/tests/fixtures/flat-config/plugin/test.jsx
@@ -0,0 +1,3 @@
+<div foo="hello">
+    test
+</div>
diff --git a/tests/flat-config.js b/tests/flat-config.js
new file mode 100644
index 00000000..79e80965
--- /dev/null
+++ b/tests/flat-config.js
@@ -0,0 +1,117 @@
+/* eslint-env mocha */
+
+'use strict';
+
+const semver = require('semver');
+const eslintPkg = require('eslint/package.json');
+
+if (!semver.satisfies(eslintPkg.version, '>= 8.23.0')) {
+  return;
+}
+
+const ESLint = semver.major(eslintPkg.version) < 9
+  ? require('eslint/use-at-your-own-risk').FlatESLint // eslint-disable-line import/no-unresolved -- false positive
+  : require('eslint').ESLint;
+
+const path = require('path');
+const assert = require('assert');
+
+describe('eslint-plugin-react in flat config', () => {
+  const fixturesdDir = path.resolve(__dirname, 'fixtures', 'flat-config');
+
+  it('should work when the plugin is used directly', () => {
+    const eslint = new ESLint({
+      cwd: path.resolve(fixturesdDir, 'plugin'),
+    });
+
+    return eslint.lintFiles(['test.jsx']).then((results) => {
+      const result = results[0];
+
+      assert.strictEqual(result.messages.length, 1);
+      assert.strictEqual(result.messages[0].severity, 1);
+      assert.strictEqual(result.messages[0].ruleId, 'react/jsx-no-literals');
+      assert.strictEqual(result.messages[0].messageId, 'literalNotInJSXExpression');
+    });
+  });
+
+  ['root', 'deep'].forEach((configAccess) => {
+    const overrideConfigFile = `eslint.config-${configAccess}.js`;
+
+    it(`should work when the plugin is used with "all" config (${configAccess})`, () => {
+      const eslint = new ESLint({
+        cwd: path.resolve(fixturesdDir, 'config-all'),
+        overrideConfigFile,
+      });
+
+      return eslint.lintFiles(['test.jsx']).then((results) => {
+        const result = results[0];
+
+        assert.strictEqual(result.messages.length, 3);
+        assert.strictEqual(result.messages[0].severity, 2);
+        assert.strictEqual(result.messages[0].ruleId, 'react/react-in-jsx-scope');
+        assert.strictEqual(result.messages[0].messageId, 'notInScope');
+        assert.strictEqual(result.messages[1].severity, 2);
+        assert.strictEqual(result.messages[1].ruleId, 'react/no-unknown-property');
+        assert.strictEqual(result.messages[1].messageId, 'unknownProp');
+        assert.strictEqual(result.messages[2].severity, 2);
+        assert.strictEqual(result.messages[2].ruleId, 'react/jsx-no-literals');
+        assert.strictEqual(result.messages[2].messageId, 'literalNotInJSXExpression');
+      });
+    });
+
+    it(`should work when the plugin is used with "recommended" config (${configAccess})`, () => {
+      const eslint = new ESLint({
+        cwd: path.resolve(fixturesdDir, 'config-recommended'),
+        overrideConfigFile,
+      });
+
+      return eslint.lintFiles(['test.jsx']).then((results) => {
+        const result = results[0];
+
+        assert.strictEqual(result.messages.length, 2);
+        assert.strictEqual(result.messages[0].severity, 2);
+        assert.strictEqual(result.messages[0].ruleId, 'react/react-in-jsx-scope');
+        assert.strictEqual(result.messages[0].messageId, 'notInScope');
+        assert.strictEqual(result.messages[1].severity, 2);
+        assert.strictEqual(result.messages[1].ruleId, 'react/no-unknown-property');
+        assert.strictEqual(result.messages[1].messageId, 'unknownProp');
+      });
+    });
+
+    it(`should work when the plugin is used with "recommended" and "jsx-runtime" configs (${configAccess})`, () => {
+      const eslint = new ESLint({
+        cwd: path.resolve(fixturesdDir, 'config-jsx-runtime'),
+        overrideConfigFile,
+      });
+
+      return eslint.lintFiles(['test.jsx']).then((results) => {
+        const result = results[0];
+
+        assert.strictEqual(result.messages.length, 1);
+        assert.strictEqual(result.messages[0].severity, 2);
+        assert.strictEqual(result.messages[0].ruleId, 'react/no-unknown-property');
+        assert.strictEqual(result.messages[0].messageId, 'unknownProp');
+      });
+    });
+
+    // https://github.com/jsx-eslint/eslint-plugin-react/issues/3693
+    it(`should work when the plugin is used directly and with "recommended" config (${configAccess})`, () => {
+      const eslint = new ESLint({
+        cwd: path.resolve(fixturesdDir, 'plugin-and-config'),
+        overrideConfigFile,
+      });
+
+      return eslint.lintFiles(['test.jsx']).then((results) => {
+        const result = results[0];
+
+        assert.strictEqual(result.messages.length, 2);
+        assert.strictEqual(result.messages[0].severity, 2);
+        assert.strictEqual(result.messages[0].ruleId, 'react/react-in-jsx-scope');
+        assert.strictEqual(result.messages[0].messageId, 'notInScope');
+        assert.strictEqual(result.messages[1].severity, 2);
+        assert.strictEqual(result.messages[1].ruleId, 'react/no-unknown-property');
+        assert.strictEqual(result.messages[1].messageId, 'unknownProp');
+      });
+    });
+  });
+});