From 87809d9326f7463e30bbf78edca0ef2a2fa139d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20Ma=C5=82ecki?= Date: Mon, 14 Apr 2025 03:02:36 -0700 Subject: [PATCH] Add `no-deep-imports` rule to eslint-plugin-react-native (#50542) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/50542 After TS types generation is completed, react native deep imports will be deprecated. This rule produces warnings to let users know to use root imports instead. For more information about why this rule was added, please check [RFC](https://github.com/react-native-community/discussions-and-proposals/pull/894). Changelog: [General][Added] - Added no-deep-imports rule to eslint-plugin-react-native. Reviewed By: robhogan Differential Revision: D71398004 fbshipit-source-id: 69104f69b1b1c59b5b0f115dcdd708a46d8d614d --- .eslintrc.js | 6 + packages/eslint-config-react-native/index.js | 6 + .../__tests__/no-deep-imports-test.js | 106 ++++++++++++++ packages/eslint-plugin-react-native/index.js | 1 + .../no-deep-imports.js | 129 ++++++++++++++++++ packages/eslint-plugin-react-native/utils.js | 109 +++++++++++++++ 6 files changed, 357 insertions(+) create mode 100644 packages/eslint-plugin-react-native/__tests__/no-deep-imports-test.js create mode 100644 packages/eslint-plugin-react-native/no-deep-imports.js create mode 100644 packages/eslint-plugin-react-native/utils.js diff --git a/.eslintrc.js b/.eslintrc.js index ab4ad80774c..5cb173db3b4 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -35,6 +35,12 @@ module.exports = { 'no-undef': 0, }, }, + { + files: ['*.js', '*.jsx', '*.ts', '*.tsx'], + rules: { + '@react-native/no-deep-imports': 0, + }, + }, { files: [ './packages/react-native/**/*.{js,flow}', diff --git a/packages/eslint-config-react-native/index.js b/packages/eslint-config-react-native/index.js index bd93c666c9e..4ee41465162 100644 --- a/packages/eslint-config-react-native/index.js +++ b/packages/eslint-config-react-native/index.js @@ -53,6 +53,12 @@ module.exports = { files: ['*.jsx'], parser: '@babel/eslint-parser', }, + { + files: ['*.js', '*.jsx', '*.ts', '*.tsx'], + rules: { + '@react-native/no-deep-imports': 1, + }, + }, { files: ['*.ts', '*.tsx'], parser: '@typescript-eslint/parser', diff --git a/packages/eslint-plugin-react-native/__tests__/no-deep-imports-test.js b/packages/eslint-plugin-react-native/__tests__/no-deep-imports-test.js new file mode 100644 index 00000000000..6c473f8d746 --- /dev/null +++ b/packages/eslint-plugin-react-native/__tests__/no-deep-imports-test.js @@ -0,0 +1,106 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @oncall react_native + */ + +'use strict'; + +const rule = require('../no-deep-imports.js'); +const {publicAPIMapping} = require('../utils.js'); +const ESLintTester = require('./eslint-tester.js'); +const path = require('path'); + +const eslintTester = new ESLintTester(); + +test('resolve all public API paths', () => { + for (const subpath of Object.keys(publicAPIMapping)) { + require.resolve(path.join('react-native', subpath)); + } +}); + +eslintTester.run('../no-deep-imports', rule, { + valid: [ + "import {View} from 'react-native';", + "const {View} = require('react-native');", + "import Foo from 'react-native-foo';", + "import Foo from 'react-native-foo/Foo';", + "import Foo from 'react/native/Foo';", + ], + invalid: [ + { + code: "import View from 'react-native/Libraries/Components/View/View';", + errors: [ + { + messageId: 'deepImport', + data: {importPath: 'react-native/Libraries/Components/View/View'}, + }, + ], + output: "import {View} from 'react-native';", + }, + { + code: "const View = require('react-native/Libraries/Components/View/View');", + errors: [ + { + messageId: 'deepImport', + data: {importPath: 'react-native/Libraries/Components/View/View'}, + }, + ], + output: "const {View} = require('react-native');", + }, + { + code: "var View = require('react-native/Libraries/Components/View/View');", + errors: [ + { + messageId: 'deepImport', + data: {importPath: 'react-native/Libraries/Components/View/View'}, + }, + ], + output: "var {View} = require('react-native');", + }, + { + code: "import Foo from 'react-native/Libraries/Components/Foo';", + errors: [ + { + messageId: 'deepImport', + data: {importPath: 'react-native/Libraries/Components/Foo'}, + }, + ], + output: null, + }, + { + code: "import {Foo} from 'react-native/Libraries/Components/Foo';", + errors: [ + { + messageId: 'deepImport', + data: {importPath: 'react-native/Libraries/Components/Foo'}, + }, + ], + output: null, + }, + { + code: "const {Foo} = require('react-native/Libraries/Foo');", + errors: [ + { + messageId: 'deepImport', + data: {importPath: 'react-native/Libraries/Foo'}, + }, + ], + output: null, + }, + { + code: "if(require('react-native/Libraries/Foo')) {};", + errors: [ + { + messageId: 'deepImport', + data: {importPath: 'react-native/Libraries/Foo'}, + }, + ], + output: null, + }, + ], +}); diff --git a/packages/eslint-plugin-react-native/index.js b/packages/eslint-plugin-react-native/index.js index 5c375618455..20235d41281 100644 --- a/packages/eslint-plugin-react-native/index.js +++ b/packages/eslint-plugin-react-native/index.js @@ -9,4 +9,5 @@ exports.rules = { 'platform-colors': require('./platform-colors'), + 'no-deep-imports': require('./no-deep-imports'), }; diff --git a/packages/eslint-plugin-react-native/no-deep-imports.js b/packages/eslint-plugin-react-native/no-deep-imports.js new file mode 100644 index 00000000000..935f36b1383 --- /dev/null +++ b/packages/eslint-plugin-react-native/no-deep-imports.js @@ -0,0 +1,129 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +'use strict'; + +const {publicAPIMapping} = require('./utils.js'); + +module.exports = { + meta: { + type: 'problem', + docs: { + description: 'Disallow deep imports from react native', + }, + messages: { + deepImport: + "'{{importPath}}' React Native deep imports are deprecated. Please use the top level import instead.", + }, + schema: [], + fixable: 'code', + }, + + create: function (context) { + return { + ImportDeclaration(node) { + if (!isDeepReactNativeImport(node.source)) { + return; + } + if (isDefaultImport(node)) { + const reactNativeSource = node.source.value.slice( + 'react-native/'.length, + ); + const publicAPIDefaultComponent = publicAPIMapping[reactNativeSource]; + if (publicAPIDefaultComponent) { + context.report({ + ...getStandardReport(node.source), + fix(fixer) { + return fixer.replaceText( + node, + `import {${publicAPIDefaultComponent}} from 'react-native';`, + ); + }, + }); + } else { + context.report(getStandardReport(node.source)); + } + } else { + context.report(getStandardReport(node.source)); + } + }, + CallExpression(node) { + if (!isDeepRequire(node)) { + return; + } + + const parent = node.parent; + const importPath = node.arguments[0].value; + + if ( + parent.type === 'VariableDeclarator' && + parent.id.type === 'Identifier' + ) { + const reactNativeSource = importPath.slice('react-native/'.length); + const publicAPIDefaultComponent = publicAPIMapping[reactNativeSource]; + if (publicAPIDefaultComponent) { + context.report({ + ...getStandardReport(node.arguments[0]), + fix(fixer) { + return fixer.replaceText( + parent, + `{${publicAPIDefaultComponent}} = require('react-native')`, + ); + }, + }); + } else { + context.report(getStandardReport(node.arguments[0])); + } + } else { + context.report(getStandardReport(node.arguments[0])); + } + }, + }; + + function getStandardReport(source) { + return { + node: source, + messageId: 'deepImport', + data: { + importPath: source.value, + }, + }; + } + + function isDefaultImport(node) { + return ( + node.specifiers.length === 1 && + node.specifiers.some( + specifier => specifier.type === 'ImportDefaultSpecifier', + ) + ); + } + + function isDeepRequire(node) { + return ( + node.callee.type === 'Identifier' && + node.callee.name === 'require' && + node.arguments.length === 1 && + node.arguments[0].type === 'Literal' && + typeof node.arguments[0].value === 'string' && + isDeepReactNativeImport(node.arguments[0]) + ); + } + + function isDeepReactNativeImport(source) { + if (source.type !== 'Literal' || typeof source.value !== 'string') { + return false; + } + + const importPath = source.value; + const parts = importPath.split('/'); + return parts.length > 1 && parts[0] === 'react-native'; + } + }, +}; diff --git a/packages/eslint-plugin-react-native/utils.js b/packages/eslint-plugin-react-native/utils.js new file mode 100644 index 00000000000..9a33116c745 --- /dev/null +++ b/packages/eslint-plugin-react-native/utils.js @@ -0,0 +1,109 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +'use strict'; + +/** + * The correctness of paths is checked in the test file. + * The assumption is that renaming/removing components shouldn't happen too often. + * If a new component is added, it should be imported from the root. + * If the path is not matched, the auto-fix won't be suggested. + */ +const publicAPIMapping = { + 'Libraries/Components/AccessibilityInfo/AccessibilityInfo': + 'AccessibilityInfo', + 'Libraries/Components/ActivityIndicator/ActivityIndicator': + 'ActivityIndicator', + 'Libraries/Components/Button': 'Button', + 'Libraries/Components/DrawerAndroid/DrawerLayoutAndroid': + 'DrawerLayoutAndroid', + 'Libraries/Components/LayoutConformance/LayoutConformance': + 'experimental_LayoutConformance', + 'Libraries/Lists/FlatList': 'FlatList', + 'Libraries/Image/Image': 'Image', + 'Libraries/Image/ImageBackground': 'ImageBackground', + 'Libraries/Components/TextInput/InputAccessoryView': 'InputAccessoryView', + 'Libraries/Components/Keyboard/KeyboardAvoidingView': 'KeyboardAvoidingView', + 'Libraries/Modal/Modal': 'Modal', + 'Libraries/Components/Pressable/Pressable': 'Pressable', + 'Libraries/Components/ProgressBarAndroid/ProgressBarAndroid': + 'ProgressBarAndroid', + 'Libraries/Components/RefreshControl/RefreshControl': 'RefreshControl', + 'Libraries/Components/SafeAreaView/SafeAreaView': 'SafeAreaView', + 'Libraries/Components/ScrollView/ScrollView': 'ScrollView', + 'Libraries/Lists/SectionList': 'SectionList', + 'Libraries/Components/StatusBar/StatusBar': 'StatusBar', + 'Libraries/Components/Switch/Switch': 'Switch', + 'Libraries/Text/Text': 'Text', + 'Libraries/Components/TextInput/TextInput': 'TextInput', + 'Libraries/Components/Touchable/Touchable': 'Touchable', + 'Libraries/Components/Touchable/TouchableHighlight': 'TouchableHighlight', + 'Libraries/Components/Touchable/TouchableNativeFeedback': + 'TouchableNativeFeedback', + 'Libraries/Components/Touchable/TouchableOpacity': 'TouchableOpacity', + 'Libraries/Components/Touchable/TouchableWithoutFeedback': + 'TouchableWithoutFeedback', + 'Libraries/Components/View/View': 'View', + 'Libraries/Lists/VirtualizedList': 'VirtualizedList', + 'Libraries/Lists/VirtualizedSectionList': 'VirtualizedSectionList', + 'Libraries/ActionSheetIOS/ActionSheetIOS': 'ActionSheetIOS', + 'Libraries/Alert/Alert': 'Alert', + 'Libraries/Animated/Animated': 'Animated', + 'Libraries/Utilities/Appearance': 'Appearance', + 'Libraries/ReactNative/AppRegistry': 'AppRegistry', + 'Libraries/AppState/AppState': 'AppState', + 'Libraries/Utilities/BackHandler': 'BackHandler', + 'Libraries/Components/Clipboard/Clipboard': 'Clipboard', + 'Libraries/Utilities/DeviceInfo': 'DeviceInfo', + 'src/private/devmenu/DevMenu': 'DevMenu', + 'Libraries/Utilities/DevSettings': 'DevSettings', + 'Libraries/Utilities/Dimensions': 'Dimensions', + 'Libraries/Animated/Easing': 'Easing', + 'Libraries/ReactNative/I18nManager': 'I18nManager', + 'Libraries/Interaction/InteractionManager': 'InteractionManager', + 'Libraries/Components/Keyboard/Keyboard': 'Keyboard', + 'Libraries/LayoutAnimation/LayoutAnimation': 'LayoutAnimation', + 'Libraries/Linking/Linking': 'Linking', + 'Libraries/LogBox/LogBox': 'LogBox', + 'Libraries/NativeModules/specs/NativeDialogManagerAndroid': + 'NativeDialogManagerAndroid', + 'Libraries/EventEmitter/NativeEventEmitter': 'NativeEventEmitter', + 'Libraries/Network/RCTNetworking': 'Networking', + 'Libraries/Interaction/PanResponder': 'PanResponder', + 'Libraries/PermissionsAndroid/PermissionsAndroid': 'PermissionsAndroid', + 'Libraries/Utilities/PixelRatio': 'PixelRatio', + 'Libraries/PushNotificationIOS/PushNotificationIOS': 'PushNotificationIOS', + 'Libraries/Settings/Settings': 'Settings', + 'Libraries/Share/Share': 'Share', + 'Libraries/StyleSheet/StyleSheet': 'StyleSheet', + 'Libraries/Performance/Systrace': 'Systrace', + 'Libraries/Components/ToastAndroid/ToastAndroid': 'ToastAndroid', + 'Libraries/TurboModule/TurboModuleRegistry': 'TurboModuleRegistry', + 'Libraries/ReactNative/UIManager': 'UIManager', + 'Libraries/Animated/useAnimatedValue': 'useAnimatedValue', + 'Libraries/Utilities/useColorScheme': 'useColorScheme', + 'Libraries/Utilities/useWindowDimensions': 'useWindowDimensions', + 'Libraries/UTFSequence': 'UTFSequence', + 'Libraries/Vibration/Vibration': 'Vibration', + 'Libraries/Utilities/codegenNativeComponent': 'codegenNativeComponent', + 'Libraries/Utilities/codegenNativeCommands': 'codegenNativeCommands', + 'Libraries/EventEmitter/RCTDeviceEventEmitter': 'DeviceEventEmitter', + 'Libraries/StyleSheet/PlatformColorValueTypesIOS': 'DynamicColorIOS', + 'Libraries/EventEmitter/RCTNativeAppEventEmitter': 'NativeAppEventEmitter', + 'Libraries/BatchedBridge/NativeModules': 'NativeModules', + 'Libraries/Utilities/Platform': 'Platform', + 'Libraries/StyleSheet/PlatformColorValueTypes': 'PlatformColor', + 'Libraries/StyleSheet/processColor': 'processColor', + 'Libraries/ReactNative/requireNativeComponent': 'requireNativeComponent', + 'Libraries/ReactNative/RootTag': 'RootTagContext', +}; + +module.exports = { + publicAPIMapping, +};