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, +};