diff --git a/README.md b/README.md index 18193b5..f20df36 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Monorepo with packages for setting up ESLint and Typescript for any new React Na ## Presentation -The goal of the project is too have a set of configuration files that can be easily imported into a new project, which would reduce the burden of starting new projects. +The goal of the project is to have a set of configuration files that can be easily imported into a new project, which would reduce the burden of starting new projects. This repo uses [lerna](https://lerna.js.org/) to maintain, version and publish various packages for configuring ESLint and Typescript. @@ -39,7 +39,7 @@ Here are some useful commands: We use [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) to automate the release process. -> If you add a new rule to a config, this is a breaking change, because it could make the CI fail on projects that use the plugin. The commit name where you add the new rule needs to follow this patern `BREAKING CHANGE : the description of your commit` +> If you add a new rule to a config, this is a breaking change, because it could make the CI fail on projects that use the plugin. The commit name where you add the new rule needs to follow this pattern `BREAKING CHANGE : the description of your commit` ## Publishing a new version of a package @@ -68,7 +68,7 @@ It will then push a tagged commit `chore(release): Publish` which will then trig ## Unpublish a package version -If you want to unpublish a package, you have to be contributor of @bam.tech/eslint-plugin (in this case for the eslint plugin). Use the following commad : +If you want to unpublish a package, you have to be contributor of @bam.tech/eslint-plugin (in this case for the eslint plugin). Use the following command : `npm unpublish @bam.tech/eslint-plugin@X.Y.Z` ## Running commands diff --git a/example-app/.eslintrc b/example-app/.eslintrc index 0eb90e7..6f0f40a 100644 --- a/example-app/.eslintrc +++ b/example-app/.eslintrc @@ -1,6 +1,10 @@ { "root": true, - "extends": ["plugin:@bam.tech/recommended", "plugin:@bam.tech/a11y"], + "extends": [ + "plugin:@bam.tech/recommended", + "plugin:@bam.tech/a11y", + "plugin:@bam.tech/performance" + ], "overrides": [ { "files": ["*.test.tsx"], diff --git a/example-app/eslint-breaking-examples/break-flatlist-import-rule.tsx b/example-app/eslint-breaking-examples/break-flatlist-import-rule.tsx new file mode 100644 index 0000000..68199cc --- /dev/null +++ b/example-app/eslint-breaking-examples/break-flatlist-import-rule.tsx @@ -0,0 +1,10 @@ +// Save without formatting: [⌘ + K] > [S] + +// This should trigger one error breaking custom FlatList import rule: +// @bam.tech/no-flatlist + +import { FlatList } from "react-native"; + +export const MyCustomButton = () => { + return ; +}; diff --git a/example-app/eslint-breaking-examples/break-intl-number-format-rule.ts b/example-app/eslint-breaking-examples/break-intl-number-format-rule.ts new file mode 100644 index 0000000..2b99350 --- /dev/null +++ b/example-app/eslint-breaking-examples/break-intl-number-format-rule.ts @@ -0,0 +1,14 @@ +// This code should trigger the ESLint rule `avoidIntlNumberFormatRule` + +const number = 1234567.89; + +// Incorrect usage: This will be flagged by the ESLint rule +const formatter = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", +}); + +const formattedNumber = formatter.format(number); + +// eslint-disable-next-line no-console +console.log(formattedNumber); // Outputs: $1,234,567.89 diff --git a/example-app/eslint-breaking-examples/break-native-driver-rule.tsx b/example-app/eslint-breaking-examples/break-native-driver-rule.tsx new file mode 100644 index 0000000..0d45ebb --- /dev/null +++ b/example-app/eslint-breaking-examples/break-native-driver-rule.tsx @@ -0,0 +1,21 @@ +import { Animated, ScrollView, Text } from "react-native"; + +const fadeAnim = new Animated.Value(0); + +Animated.timing(fadeAnim, { + toValue: 1, + duration: 500, + useNativeDriver: false, // This line breaks the custom rule +}).start(); + +export const CustomScrollView = () => { + return ( + + {"Something to scroll"} + + ); +}; diff --git a/example-app/eslint-breaking-examples/break-react-native-svg-import-rule.tsx b/example-app/eslint-breaking-examples/break-react-native-svg-import-rule.tsx new file mode 100644 index 0000000..15bda5b --- /dev/null +++ b/example-app/eslint-breaking-examples/break-react-native-svg-import-rule.tsx @@ -0,0 +1,30 @@ +// Save without formatting: [⌘ + K] > [S] + +// This should trigger one error breaking eslint-plugin-react-native: +// no-restricted-imports + +import Svg, { Circle, Rect } from "react-native-svg"; + +export const SvgComponent = () => { + return ( + + + + + ); +}; diff --git a/example-app/eslint-breaking-examples/break-react-navigation-stack-import-rule.tsx b/example-app/eslint-breaking-examples/break-react-navigation-stack-import-rule.tsx new file mode 100644 index 0000000..0186d3d --- /dev/null +++ b/example-app/eslint-breaking-examples/break-react-navigation-stack-import-rule.tsx @@ -0,0 +1,12 @@ +// Save without formatting: [⌘ + K] > [S] + +// This should trigger one error breaking custom react-navigation/stack rule: +// @bam.tech/no-react-navigation-stack + +import { createStackNavigator } from "@react-navigation/stack"; + +const Stack = createStackNavigator(); + +export const MyStack = () => { + return ; +}; diff --git a/example-app/eslint-breaking-examples/break-use-is-focused-import-rule.tsx b/example-app/eslint-breaking-examples/break-use-is-focused-import-rule.tsx new file mode 100644 index 0000000..c5167e5 --- /dev/null +++ b/example-app/eslint-breaking-examples/break-use-is-focused-import-rule.tsx @@ -0,0 +1,13 @@ +// Save without formatting: [⌘ + K] > [S] + +// This should trigger one error breaking custom performance rule: +// @bam.tech/no-use-is-focused + +import { useIsFocused } from "@react-navigation/native"; +import { Text } from "react-native"; + +export const MyComponent = () => { + const isFocused = useIsFocused(); + + return {isFocused ? "focused" : "unfocused"}; +}; diff --git a/example-app/package.json b/example-app/package.json index 47f73a5..ba11e3f 100644 --- a/example-app/package.json +++ b/example-app/package.json @@ -11,6 +11,8 @@ "devDependencies": { "@bam.tech/eslint-plugin": "*", "@bam.tech/typescript-config": "*", + "@react-navigation/native": "^6.1.18", + "@react-navigation/stack": "^6.4.1", "@testing-library/react-native": "^12.3.1", "@types/jest": "^29.5.2", "@types/react": "^18.2.14", @@ -37,6 +39,7 @@ "dependencies": { "expo": "^49.0.0", "react": "^18.2.0", - "react-native": "^0.73.0" + "react-native": "^0.73.0", + "react-native-svg": "^15.5.0" } } diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index 783392a..f0ca4ad 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -103,16 +103,23 @@ This plugin exports some custom rules that you can optionally use in your projec 💼 Configurations enabled in.\ +⚠️ Configurations set to warn in.\ ✅ Set in the `recommended` configuration.\ 🧪 Set in the `tests` configuration.\ 🔧 Automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/user-guide/command-line-interface#--fix). -| Name | Description | 💼 | 🔧 | -| :-------------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------- | :-- | :-- | -| [await-user-event](https://github.com/bamlab/react-native-project-config/blob/main/packages/eslint-plugin/docs/rules/await-user-event.md) | Enforces awaiting userEvent calls | 🧪 | 🔧 | -| [no-different-displayname](https://github.com/bamlab/react-native-project-config/blob/main/packages/eslint-plugin/docs/rules/no-different-displayname.md) | Enforce component displayName to match with component name | ✅ | 🔧 | -| [prefer-user-event](https://github.com/bamlab/react-native-project-config/blob/main/packages/eslint-plugin/docs/rules/prefer-user-event.md) | Enforces usage of userEvent over fireEvent in tests. | | 🔧 | -| [require-named-effect](https://github.com/bamlab/react-native-project-config/blob/main/packages/eslint-plugin/docs/rules/require-named-effect.md) | Enforces the use of named functions inside a useEffect | | | +| Name                              | Description | 💼 | ⚠️ | 🔧 | +| :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------ | :--------------------- | :--------------------- | :-- | +| [avoid-intl-number-format](https://github.com/bamlab/react-native-project-config/blob/main/packages/eslint-plugin/docs/rules/avoid-intl-number-format.md) | Disallow the use of `Intl.NumberFormat` due to potential performance issues. | ![badge-performance][] | | | +| [avoid-react-native-svg](https://github.com/bamlab/react-native-project-config/blob/main/packages/eslint-plugin/docs/rules/avoid-react-native-svg.md) | Disallow importing the `react-native-svg` package. | | ![badge-performance][] | | +| [await-user-event](https://github.com/bamlab/react-native-project-config/blob/main/packages/eslint-plugin/docs/rules/await-user-event.md) | Enforces awaiting userEvent calls | 🧪 | | 🔧 | +| [no-animated-without-native-driver](https://github.com/bamlab/react-native-project-config/blob/main/packages/eslint-plugin/docs/rules/no-animated-without-native-driver.md) | Disallow the use of `Animated` with `useNativeDriver: false` | ![badge-performance][] | | | +| [no-different-displayname](https://github.com/bamlab/react-native-project-config/blob/main/packages/eslint-plugin/docs/rules/no-different-displayname.md) | Enforce component displayName to match with component name | ✅ | | 🔧 | +| [no-flatlist](https://github.com/bamlab/react-native-project-config/blob/main/packages/eslint-plugin/docs/rules/no-flatlist.md) | Disallow importing `FlatList` from `react-native` due to potential performance concerns or the preference for alternative components. | ![badge-performance][] | | 🔧 | +| [no-react-navigation-stack](https://github.com/bamlab/react-native-project-config/blob/main/packages/eslint-plugin/docs/rules/no-react-navigation-stack.md) | Disallow importing from `@react-navigation/stack` and suggest using `@react-navigation/native-stack` instead. | ![badge-performance][] | | | +| [no-use-is-focused](https://github.com/bamlab/react-native-project-config/blob/main/packages/eslint-plugin/docs/rules/no-use-is-focused.md) | Disallow importing `useIsFocused` from `@react-navigation/native` to encourage using `useFocusEffect` instead. | ![badge-performance][] | | 🔧 | +| [prefer-user-event](https://github.com/bamlab/react-native-project-config/blob/main/packages/eslint-plugin/docs/rules/prefer-user-event.md) | Enforces usage of userEvent over fireEvent in tests. | | | 🔧 | +| [require-named-effect](https://github.com/bamlab/react-native-project-config/blob/main/packages/eslint-plugin/docs/rules/require-named-effect.md) | Enforces the use of named functions inside a useEffect | | | | diff --git a/packages/eslint-plugin/docs/rules/avoid-intl-number-format.md b/packages/eslint-plugin/docs/rules/avoid-intl-number-format.md new file mode 100644 index 0000000..a1b9913 --- /dev/null +++ b/packages/eslint-plugin/docs/rules/avoid-intl-number-format.md @@ -0,0 +1,7 @@ +# Disallow the use of `Intl.NumberFormat` due to potential performance issues (`@bam.tech/avoid-intl-number-format`) + +💼 This rule is enabled in the `performance` config. + + + +Prevents from the using `Intl.NumberFormat` to improve performance. diff --git a/packages/eslint-plugin/docs/rules/avoid-react-native-svg.md b/packages/eslint-plugin/docs/rules/avoid-react-native-svg.md new file mode 100644 index 0000000..d9f355b --- /dev/null +++ b/packages/eslint-plugin/docs/rules/avoid-react-native-svg.md @@ -0,0 +1,19 @@ +# Disallow importing the `react-native-svg` package (`@bam.tech/avoid-react-native-svg`) + +⚠️ This rule _warns_ in the `performance` config. + + + +Prevents from using "react-native-svg" import to avoid performance issues. + +## Rule details + +Examples of **incorrect** code for this rule: + +```jsx +import Svg from "react-native-svg"; +``` + +```jsx +const Svg = require("react-native-svg"); +``` diff --git a/packages/eslint-plugin/docs/rules/no-animated-without-native-driver.md b/packages/eslint-plugin/docs/rules/no-animated-without-native-driver.md new file mode 100644 index 0000000..0f62f47 --- /dev/null +++ b/packages/eslint-plugin/docs/rules/no-animated-without-native-driver.md @@ -0,0 +1,29 @@ +# Disallow the use of `Animated` with `useNativeDriver: false` (`@bam.tech/no-animated-without-native-driver`) + +💼 This rule is enabled in the `performance` config. + + + +Enforces the usage of native driver when using `Animated` from `react-native` to improve performance. + +## Rule details + +Example of **incorrect** code for this rule: + +```jsx +Animated.timing(fadeAnim, { + toValue: 1, + duration: 500, + useNativeDriver: false, +}).start(); +``` + +Example of **correct** code for this rule: + +```jsx +Animated.timing(fadeAnim, { + toValue: 1, + duration: 500, + useNativeDriver: true, +}).start(); +``` diff --git a/packages/eslint-plugin/docs/rules/no-flatlist.md b/packages/eslint-plugin/docs/rules/no-flatlist.md new file mode 100644 index 0000000..70d1f24 --- /dev/null +++ b/packages/eslint-plugin/docs/rules/no-flatlist.md @@ -0,0 +1,27 @@ +# Disallow importing `FlatList` from `react-native` due to potential performance concerns or the preference for alternative components (`@bam.tech/no-flatlist`) + +💼 This rule is enabled in the `performance` config. + +🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). + + + +Prevents from using "FlatList" import to avoid performance issues. FlashList should be used instead. + +## Rule details + +Examples of **incorrect** code for this rule: + +```jsx +import { FlatList } from "react-native"; +``` + +```jsx +import { FlatList, SectionList } from "react-native"; +``` + +Examples of **correct** alternative for this rule: + +```jsx +import { FlashList } from "@shopify/flash-list"; +``` diff --git a/packages/eslint-plugin/docs/rules/no-react-navigation-stack.md b/packages/eslint-plugin/docs/rules/no-react-navigation-stack.md new file mode 100644 index 0000000..89ded8a --- /dev/null +++ b/packages/eslint-plugin/docs/rules/no-react-navigation-stack.md @@ -0,0 +1,21 @@ +# Disallow importing from `@react-navigation/stack` and suggest using `@react-navigation/native-stack` instead (`@bam.tech/no-react-navigation-stack`) + +💼 This rule is enabled in the `performance` config. + + + +Prevents from using "react-navigation/stack" import to avoid performance issues. "react-navigation/native-stack" should be used instead. + +## Rule details + +Examples of **incorrect** code for this rule: + +```jsx +import { createStackNavigator } from "@react-navigation/stack"; +``` + +Examples of **correct** alternative for this rule: + +```jsx +import { createStackNavigator } from "@react-navigation/native-stack"; +``` diff --git a/packages/eslint-plugin/docs/rules/no-use-is-focused.md b/packages/eslint-plugin/docs/rules/no-use-is-focused.md new file mode 100644 index 0000000..6d91a4d --- /dev/null +++ b/packages/eslint-plugin/docs/rules/no-use-is-focused.md @@ -0,0 +1,17 @@ +# Disallow importing `useIsFocused` from `@react-navigation/native` to encourage using `useFocusEffect` instead (`@bam.tech/no-use-is-focused`) + +💼 This rule is enabled in the `performance` config. + +🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). + + + +Prevents from using "useIsFocused" to avoid performance issues. "useFocusEffect" should be used instead. + +## Rule details + +Examples of **incorrect** code for this rule: + +```jsx +import { useIsFocused } from "@react-navigation/native"; +``` diff --git a/packages/eslint-plugin/lib/configs/a11y.ts b/packages/eslint-plugin/lib/configs/a11y.ts index 85fdc20..edae110 100644 --- a/packages/eslint-plugin/lib/configs/a11y.ts +++ b/packages/eslint-plugin/lib/configs/a11y.ts @@ -1,6 +1,6 @@ import { defineConfig } from "eslint-define-config"; -export const a11yconfig = defineConfig({ +export const a11yConfig = defineConfig({ extends: ["plugin:react-native-a11y/all"], rules: { "react-native-a11y/has-accessibility-hint": "off", diff --git a/packages/eslint-plugin/lib/configs/index.ts b/packages/eslint-plugin/lib/configs/index.ts index b38a5ac..e304f4d 100644 --- a/packages/eslint-plugin/lib/configs/index.ts +++ b/packages/eslint-plugin/lib/configs/index.ts @@ -1,11 +1,13 @@ -import { a11yconfig } from "./a11y"; +import { a11yConfig } from "./a11y"; import { importConfig } from "./import"; import { recommendedConfig } from "./recommended"; import { testsConfig } from "./tests"; +import { performanceConfig } from "./performance"; export default { recommended: recommendedConfig, tests: testsConfig, - a11y: a11yconfig, + a11y: a11yConfig, import: importConfig, + performance: performanceConfig, }; diff --git a/packages/eslint-plugin/lib/configs/performance.ts b/packages/eslint-plugin/lib/configs/performance.ts new file mode 100644 index 0000000..2a59ff8 --- /dev/null +++ b/packages/eslint-plugin/lib/configs/performance.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "eslint-define-config"; + +export const performanceConfig = defineConfig({ + rules: { + "@bam.tech/no-animated-without-native-driver": "error", + "@bam.tech/avoid-intl-number-format": "error", + "@bam.tech/avoid-react-native-svg": "warn", + "@bam.tech/no-flatlist": "error", + "@bam.tech/no-react-navigation-stack": "error", + "@bam.tech/no-use-is-focused": "error", + }, + overrides: [ + { + files: ["*.js"], + }, + ], +}); diff --git a/packages/eslint-plugin/lib/rules/avoid-intl-number-format.ts b/packages/eslint-plugin/lib/rules/avoid-intl-number-format.ts new file mode 100644 index 0000000..5830e27 --- /dev/null +++ b/packages/eslint-plugin/lib/rules/avoid-intl-number-format.ts @@ -0,0 +1,39 @@ +import type { Rule } from "eslint"; + +// Custom Rule: No Intl.NumberFormat Usage +export const avoidIntlNumberFormatRule: Rule.RuleModule = { + meta: { + type: "problem", + docs: { + description: + "Disallow the use of `Intl.NumberFormat` due to potential performance issues.", + category: "Possible Errors", + recommended: true, + url: "https://github.com/bamlab/react-native-project-config/tree/main/packages/eslint-plugin/docs/rules/no-intl-numberformat.md", + }, + messages: { + noIntlNumberFormat: + "Avoid using `Intl.NumberFormat` as it can lead to performance issues. Consider using a lightweight formatting alternative or memoizing the formatter instance.", + }, + schema: [], + }, + + create(context) { + return { + NewExpression(node) { + if ( + node.callee.type === "MemberExpression" && + node.callee.object.type === "Identifier" && + node.callee.object.name === "Intl" && + node.callee.property.type === "Identifier" && + node.callee.property.name === "NumberFormat" + ) { + context.report({ + node, + messageId: "noIntlNumberFormat", + }); + } + }, + }; + }, +}; diff --git a/packages/eslint-plugin/lib/rules/avoid-react-native-svg.ts b/packages/eslint-plugin/lib/rules/avoid-react-native-svg.ts new file mode 100644 index 0000000..b82489a --- /dev/null +++ b/packages/eslint-plugin/lib/rules/avoid-react-native-svg.ts @@ -0,0 +1,46 @@ +import type { Rule } from "eslint"; + +// Custom Rule: No react-native-svg Import +export const avoidReactNativeSvgImportRule: Rule.RuleModule = { + meta: { + type: "problem", + docs: { + description: "Disallow importing the `react-native-svg` package.", + category: "Possible Errors", + recommended: true, + url: "https://github.com/bamlab/react-native-project-config/tree/main/packages/eslint-plugin/docs/rules/no-react-native-svg-import.md", + }, + messages: { + noReactNativeSvgImport: + "Do not import `react-native-svg`. Consider using an alternative method for SVG handling or ensure it's necessary for your use case.", + }, + schema: [], + }, + + create(context) { + return { + ImportDeclaration(node) { + if (node.source.value === "react-native-svg") { + context.report({ + node, + messageId: "noReactNativeSvgImport", + }); + } + }, + CallExpression(node) { + if ( + node.callee.type === "Identifier" && + node.callee.name === "require" && + node.arguments.length > 0 && + node.arguments[0].type === "Literal" && + node.arguments[0].value === "react-native-svg" + ) { + context.report({ + node, + messageId: "noReactNativeSvgImport", + }); + } + }, + }; + }, +}; diff --git a/packages/eslint-plugin/lib/rules/index.ts b/packages/eslint-plugin/lib/rules/index.ts index a9e6841..26fc809 100644 --- a/packages/eslint-plugin/lib/rules/index.ts +++ b/packages/eslint-plugin/lib/rules/index.ts @@ -1,11 +1,23 @@ +import { avoidIntlNumberFormatRule } from "./avoid-intl-number-format"; +import { avoidReactNativeSvgImportRule } from "./avoid-react-native-svg"; import { awaitUserEventRule } from "./await-user-event"; import { noDifferentDisplaynameRule } from "./no-different-displayname"; +import { noAnimatedWithoutNativeDriverRule } from "./no-animated-without-native-driver"; import { preferUserEventRule } from "./prefer-user-event"; import { requireNamedEffectRule } from "./require-named-effect"; +import { noFlatListImportRule } from "./no-flatlist"; +import { noReactNavigationStackImportRule } from "./no-react-navigation-stack"; +import { noUseIsFocusedImportRule } from "./no-use-is-focused"; export default { "await-user-event": awaitUserEventRule, "prefer-user-event": preferUserEventRule, "require-named-effect": requireNamedEffectRule, "no-different-displayname": noDifferentDisplaynameRule, + "no-animated-without-native-driver": noAnimatedWithoutNativeDriverRule, + "avoid-intl-number-format": avoidIntlNumberFormatRule, + "avoid-react-native-svg": avoidReactNativeSvgImportRule, + "no-flatlist": noFlatListImportRule, + "no-react-navigation-stack": noReactNavigationStackImportRule, + "no-use-is-focused": noUseIsFocusedImportRule, }; diff --git a/packages/eslint-plugin/lib/rules/no-animated-without-native-driver.ts b/packages/eslint-plugin/lib/rules/no-animated-without-native-driver.ts new file mode 100644 index 0000000..96dad51 --- /dev/null +++ b/packages/eslint-plugin/lib/rules/no-animated-without-native-driver.ts @@ -0,0 +1,81 @@ +import type { Rule } from "eslint"; + +// Custom Rule: No Animated with useNativeDriver: false +export const noAnimatedWithoutNativeDriverRule: Rule.RuleModule = { + meta: { + type: "problem", + docs: { + description: + "Disallow the use of `Animated` with `useNativeDriver: false`", + category: "Possible Errors", + recommended: true, + url: "https://github.com/bamlab/react-native-project-config/tree/main/packages/eslint-plugin/docs/rules/no-animated-without-native-driver.md", + }, + messages: { + noNativeDriverFalse: + "Do not use Animated with useNativeDriver: false. Always set useNativeDriver: true for better performance.", + }, + schema: [], + }, + + create(context) { + return { + CallExpression(node) { + // Check if the node is a call to `Animated` object + if ( + node.callee.type === "MemberExpression" && + node.callee.object.type === "Identifier" && + node.callee.object.name === "Animated" + ) { + // Handle the case: Animated.someMethod(..., { useNativeDriver: false }) + if ( + node.arguments.length > 0 && + node.arguments[1].type === "ObjectExpression" + ) { + const useNativeDriverPropertyIsFalse = + node.arguments[1].properties.find( + (prop) => + prop.type === "Property" && + prop.key.type === "Identifier" && + prop.key.name === "useNativeDriver" && + prop.value.type === "Literal" && + prop.value.value === false, + ); + + if (useNativeDriverPropertyIsFalse) { + context.report({ + node: useNativeDriverPropertyIsFalse, + messageId: "noNativeDriverFalse", + }); + } + } + + // Handle the case: Animated.event([...], { useNativeDriver: false }) + if ( + node.callee.property.type === "Identifier" && + node.callee.property.name === "event" && + node.arguments.length > 1 && + node.arguments[1].type === "ObjectExpression" + ) { + const useNativeDriverPropertyIsFalse = + node.arguments[1].properties.find( + (prop) => + prop.type === "Property" && + prop.key.type === "Identifier" && + prop.key.name === "useNativeDriver" && + prop.value.type === "Literal" && + prop.value.value === false, + ); + + if (useNativeDriverPropertyIsFalse) { + context.report({ + node: useNativeDriverPropertyIsFalse, + messageId: "noNativeDriverFalse", + }); + } + } + } + }, + }; + }, +}; diff --git a/packages/eslint-plugin/lib/rules/no-flatlist.ts b/packages/eslint-plugin/lib/rules/no-flatlist.ts new file mode 100644 index 0000000..84f1069 --- /dev/null +++ b/packages/eslint-plugin/lib/rules/no-flatlist.ts @@ -0,0 +1,68 @@ +import type { Rule } from "eslint"; + +// Custom Rule: No FlatList Import +export const noFlatListImportRule: Rule.RuleModule = { + meta: { + type: "problem", + docs: { + description: + "Disallow importing `FlatList` from `react-native` due to potential performance concerns or the preference for alternative components.", + category: "Possible Errors", + recommended: true, + url: "https://github.com/bamlab/react-native-project-config/tree/main/packages/eslint-plugin/docs/rules/no-flatlist.md", + }, + messages: { + noFlatListImport: + "FlatList is poorly optimized for performance, use FlashList from @shopify/flash-list for adequate list performance.", + }, + schema: [], + fixable: "code", + }, + + create(context) { + return { + ImportDeclaration(node) { + if ( + node.source.value === "react-native" && + node.specifiers.some( + (specifier) => + specifier.type === "ImportSpecifier" && + specifier.imported.name === "FlatList", + ) + ) { + context.report({ + node, + messageId: "noFlatListImport", + }); + } + }, + VariableDeclarator(node) { + if ( + node.init && + node.init.type === "CallExpression" && + node.init.callee.type === "Identifier" && + node.init.callee.name === "require" && + node.init.arguments.length > 0 && + node.init.arguments[0].type === "Literal" && + node.init.arguments[0].value === "react-native" + ) { + const flatListBinding = + node.id.type === "ObjectPattern" && + node.id.properties.some( + (property) => + property.type === "Property" && + property.key.type === "Identifier" && + property.key.name === "FlatList", + ); + + if (flatListBinding) { + context.report({ + node, + messageId: "noFlatListImport", + }); + } + } + }, + }; + }, +}; diff --git a/packages/eslint-plugin/lib/rules/no-react-navigation-stack.ts b/packages/eslint-plugin/lib/rules/no-react-navigation-stack.ts new file mode 100644 index 0000000..b3c312d --- /dev/null +++ b/packages/eslint-plugin/lib/rules/no-react-navigation-stack.ts @@ -0,0 +1,49 @@ +import type { Rule } from "eslint"; + +// Custom Rule: No Import from @react-navigation/stack +export const noReactNavigationStackImportRule: Rule.RuleModule = { + meta: { + type: "problem", + docs: { + description: + "Disallow importing from `@react-navigation/stack` and suggest using `@react-navigation/native-stack` instead.", + category: "Best Practices", + recommended: true, + url: "https://github.com/bamlab/react-native-project-config/tree/main/packages/eslint-plugin/docs/rules/no-react-navigation-stack.md", + }, + messages: { + noReactNavigationStackImport: + '"@react-navigation/native-stack" provides out of the box native screens and native transitions for better performance and user experience.', + }, + schema: [], + }, + + create(context) { + return { + ImportDeclaration(node) { + // Check if the import is from "@react-navigation/stack" + if (node.source.value === "@react-navigation/stack") { + context.report({ + node, + messageId: "noReactNavigationStackImport", + }); + } + }, + CallExpression(node) { + // Check if require() is used to import "@react-navigation/stack" + if ( + node.callee.type === "Identifier" && + node.callee.name === "require" && + node.arguments.length > 0 && + node.arguments[0].type === "Literal" && + node.arguments[0].value === "@react-navigation/stack" + ) { + context.report({ + node, + messageId: "noReactNavigationStackImport", + }); + } + }, + }; + }, +}; diff --git a/packages/eslint-plugin/lib/rules/no-use-is-focused.ts b/packages/eslint-plugin/lib/rules/no-use-is-focused.ts new file mode 100644 index 0000000..6d42404 --- /dev/null +++ b/packages/eslint-plugin/lib/rules/no-use-is-focused.ts @@ -0,0 +1,74 @@ +import type { Rule } from "eslint"; +import type { ImportDeclaration, CallExpression, Property } from "estree"; + +// Custom Rule: No Import of useIsFocused from @react-navigation/native +export const noUseIsFocusedImportRule: Rule.RuleModule = { + meta: { + type: "problem", + docs: { + description: + "Disallow importing `useIsFocused` from `@react-navigation/native` to encourage using `useFocusEffect` instead.", + category: "Best Practices", + recommended: true, + url: "https://github.com/bamlab/react-native-project-config/tree/main/packages/eslint-plugin/docs/rules/no-use-is-focused.md", + }, + messages: { + noUseIsFocusedImport: + "Please use 'useFocusEffect' instead of 'useIsFocused' to avoid excessive rerenders: 'useIsFocused' will trigger rerender both when the page goes in and out of focus.", + }, + schema: [], + fixable: "code", + }, + + create(context) { + return { + ImportDeclaration(node: ImportDeclaration) { + if (node.source.value === "@react-navigation/native") { + node.specifiers.forEach((specifier) => { + if ( + specifier.type === "ImportSpecifier" && + specifier.imported.name === "useIsFocused" + ) { + context.report({ + node: specifier, + messageId: "noUseIsFocusedImport", + }); + } + }); + } + }, + CallExpression(node: CallExpression) { + if ( + node.callee.type === "Identifier" && + node.callee.name === "require" && + node.arguments.length > 0 && + node.arguments[0].type === "Literal" && + node.arguments[0].value === "@react-navigation/native" + ) { + const ancestors = context.getAncestors(); + const parent = ancestors[ancestors.length - 1]; // Get the direct parent of the node + + if ( + parent.type === "VariableDeclarator" && + parent.id.type === "ObjectPattern" + ) { + const properties = parent.id.properties as Property[]; + const useIsFocusedProperty = properties.find( + (prop) => + prop.type === "Property" && + prop.key.type === "Identifier" && + prop.key.name === "useIsFocused", + ); + + if (useIsFocusedProperty) { + context.report({ + node: useIsFocusedProperty, + messageId: "noUseIsFocusedImport", + }); + } + } + } + }, + }; + }, +}; diff --git a/packages/eslint-plugin/tests/lib/rules/avoid-intl-number-format.test.ts b/packages/eslint-plugin/tests/lib/rules/avoid-intl-number-format.test.ts new file mode 100644 index 0000000..e52e0f5 --- /dev/null +++ b/packages/eslint-plugin/tests/lib/rules/avoid-intl-number-format.test.ts @@ -0,0 +1,43 @@ +// Save without formatting: [⌘ + K] > [S] + +// This should trigger an error breaking eslint-plugin-bam-custom-rules: +// bam-custom-rules/avoid-intl-number-format + +import { avoidIntlNumberFormatRule } from "../../../lib/rules/avoid-intl-number-format"; +import { RuleTester } from "eslint"; + +const ruleTester = new RuleTester({ + parser: require.resolve("@typescript-eslint/parser"), +}); + +const valid = [ + ` const formatCurrency = (number: number) => { + return numeral(number).format('$0,0.00'); + }; + + const number = 1234567.89; + console.log(formatCurrency(number));`, +]; + +const invalid = [ + ` const number = 1234567.89; + + const formatter = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }); + + const formattedNumber = formatter.format(number); + + console.log(formattedNumber);`, +]; + +ruleTester.run("no-animated-without-native-driver", avoidIntlNumberFormatRule, { + valid, + invalid: invalid.map((code) => ({ + code, + errors: [ + "Avoid using `Intl.NumberFormat` as it can lead to performance issues. Consider using a lightweight formatting alternative or memoizing the formatter instance.", + ], + })), +}); diff --git a/packages/eslint-plugin/tests/lib/rules/avoid-react-native-svg.test.ts b/packages/eslint-plugin/tests/lib/rules/avoid-react-native-svg.test.ts new file mode 100644 index 0000000..b60af82 --- /dev/null +++ b/packages/eslint-plugin/tests/lib/rules/avoid-react-native-svg.test.ts @@ -0,0 +1,28 @@ +// Save without formatting: [⌘ + K] > [S] + +// This should trigger an error breaking eslint-plugin-bam-custom-rules: +// bam-custom-rules/avoid-react-native-svg + +import { avoidReactNativeSvgImportRule } from "../../../lib/rules/avoid-react-native-svg"; +import { RuleTester } from "eslint"; + +const ruleTester = new RuleTester({ + parser: require.resolve("@typescript-eslint/parser"), +}); + +const valid = [``]; + +const invalid = [ + `import Svg from 'react-native-svg';`, + `const Svg = require('react-native-svg');`, +]; + +ruleTester.run("avoid-react-native-svg", avoidReactNativeSvgImportRule, { + valid, + invalid: invalid.map((code) => ({ + code, + errors: [ + "Do not import `react-native-svg`. Consider using an alternative method for SVG handling or ensure it's necessary for your use case.", + ], + })), +}); diff --git a/packages/eslint-plugin/tests/lib/rules/no-animated-without-native-driver.test.ts b/packages/eslint-plugin/tests/lib/rules/no-animated-without-native-driver.test.ts new file mode 100644 index 0000000..6c8fd57 --- /dev/null +++ b/packages/eslint-plugin/tests/lib/rules/no-animated-without-native-driver.test.ts @@ -0,0 +1,41 @@ +// Save without formatting: [⌘ + K] > [S] + +// This should trigger an error breaking eslint-plugin-bam-custom-rules: +// bam-custom-rules/no-animated-without-native-driver + +import { noAnimatedWithoutNativeDriverRule } from "../../../lib/rules/no-animated-without-native-driver"; +import { RuleTester } from "eslint"; + +const ruleTester = new RuleTester({ + parser: require.resolve("@typescript-eslint/parser"), +}); + +const valid = [ + `Animated.timing(fadeAnim, { + toValue: 1, + duration: 500, + useNativeDriver: true, + }).start();`, +]; + +const invalid = [ + `Animated.timing(fadeAnim, { + toValue: 1, + duration: 500, + useNativeDriver: false, + }).start();`, +]; + +ruleTester.run( + "no-animated-without-native-driver", + noAnimatedWithoutNativeDriverRule, + { + valid, + invalid: invalid.map((code) => ({ + code, + errors: [ + "Do not use Animated with useNativeDriver: false. Always set useNativeDriver: true for better performance.", + ], + })), + }, +); diff --git a/packages/eslint-plugin/tests/lib/rules/no-flatlist.test.ts b/packages/eslint-plugin/tests/lib/rules/no-flatlist.test.ts new file mode 100644 index 0000000..4d2487a --- /dev/null +++ b/packages/eslint-plugin/tests/lib/rules/no-flatlist.test.ts @@ -0,0 +1,28 @@ +// Save without formatting: [⌘ + K] > [S] + +// This should trigger an error breaking eslint-plugin-bam-custom-rules: +// bam-custom-rules/no-flatlist + +import { noFlatListImportRule } from "../../../lib/rules/no-flatlist"; +import { RuleTester } from "eslint"; + +const ruleTester = new RuleTester({ + parser: require.resolve("@typescript-eslint/parser"), +}); + +const valid = [`import { FlashList } from "@shopify/flash-list";`]; + +const invalid = [ + `import { FlatList } from "react-native";`, + `import { FlatList, SectionList} from 'react-native';`, +]; + +ruleTester.run("no-flatlist", noFlatListImportRule, { + valid, + invalid: invalid.map((code) => ({ + code, + errors: [ + "FlatList is poorly optimized for performance, use FlashList from @shopify/flash-list for adequate list performance.", + ], + })), +}); diff --git a/packages/eslint-plugin/tests/lib/rules/no-react-navigation-stack.test.ts b/packages/eslint-plugin/tests/lib/rules/no-react-navigation-stack.test.ts new file mode 100644 index 0000000..641b740 --- /dev/null +++ b/packages/eslint-plugin/tests/lib/rules/no-react-navigation-stack.test.ts @@ -0,0 +1,30 @@ +// Save without formatting: [⌘ + K] > [S] + +// This should trigger an error breaking eslint-plugin-bam-custom-rules: +// bam-custom-rules/no-react-navigation-stack + +import { noReactNavigationStackImportRule } from "../../../lib/rules/no-react-navigation-stack"; +import { RuleTester } from "eslint"; + +const ruleTester = new RuleTester({ + parser: require.resolve("@typescript-eslint/parser"), +}); + +const valid = [ + `import { createStackNavigator } from "@react-navigation/native-stack";`, +]; + +const invalid = [ + `import { createStackNavigator } from "@react-navigation/stack";`, + `import {createStackNavigator} from '@react-navigation/stack';`, +]; + +ruleTester.run("no-react-navigation-stack", noReactNavigationStackImportRule, { + valid, + invalid: invalid.map((code) => ({ + code, + errors: [ + `"@react-navigation/native-stack" provides out of the box native screens and native transitions for better performance and user experience.`, + ], + })), +}); diff --git a/packages/eslint-plugin/tests/lib/rules/no-use-is-focused.test.ts b/packages/eslint-plugin/tests/lib/rules/no-use-is-focused.test.ts new file mode 100644 index 0000000..9c865a9 --- /dev/null +++ b/packages/eslint-plugin/tests/lib/rules/no-use-is-focused.test.ts @@ -0,0 +1,25 @@ +// Save without formatting: [⌘ + K] > [S] + +// This should trigger an error breaking eslint-plugin-bam-custom-rules: +// bam-custom-rules/no-use-is-focused + +import { noUseIsFocusedImportRule } from "../../../lib/rules/no-use-is-focused"; +import { RuleTester } from "eslint"; + +const ruleTester = new RuleTester({ + parser: require.resolve("@typescript-eslint/parser"), +}); + +const valid = [`import { useFocusEffect } from "@react-navigation/native";`]; + +const invalid = [`import { useIsFocused } from "@react-navigation/native";`]; + +ruleTester.run("no-use-is-focused", noUseIsFocusedImportRule, { + valid, + invalid: invalid.map((code) => ({ + code, + errors: [ + `Please use 'useFocusEffect' instead of 'useIsFocused' to avoid excessive rerenders: 'useIsFocused' will trigger rerender both when the page goes in and out of focus.`, + ], + })), +});