Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(lint): add rule useComponentExportOnlyModules #3576

Merged
merged 33 commits into from
Sep 19, 2024

Conversation

GunseiKPaseri
Copy link
Contributor

@GunseiKPaseri GunseiKPaseri commented Aug 2, 2024

Summary

Implement useComponentsOnlyModule( inspired eslint-plugin-react-refresh)

Closes #3560

This rule applies only to .jsx files. (Since .js files are also treated as .jsx, it is necessary to check the file extension.)
It scans the declarations in the file and enumerates the following:

  • Component declarations that are not exported
  • Exported components
  • Exported non-components

The determination of whether something is a component or not is done simply by checking if the function or variable name is in PascalCase. (Exceptionally, cases where standard React functions like memo are used are also considered as components.)
The enumeration of exports is achieved by searching for JsExport and using a newly implemented utility (get_exported_items).
This utility extracts the names of declarations and the expressions being exported within JsExport. The declaration names are used to determine whether they are components, and the expressions are used for exception handling specific to React.

Based on the count of each of these, it outputs appropriate errors to ensure that the condition "components must always be exported, and only components should be exported" is met.

When attempting to export both components and non-components simultaneously, it instructs to move the export of non-components to a separate file.
If there are components that are not exported, it instructs to move the component to a new file (with a slightly different error message depending on whether there are other exported components).
No error is output in other cases.
Options have been implemented to ignore the export of constants (allowConstantExport), to treat specific non-PascalCase exports as components (allowExportNames), and to enforce the check on .js files as well (checkJS).
checkJS has not been implemented.

Test Plan

  • The added tests function correctly

@github-actions github-actions bot added A-CLI Area: CLI A-Project Area: project A-Linter Area: linter A-Parser Area: parser L-JavaScript Language: JavaScript and super languages A-Diagnostic Area: diagnostocis labels Aug 2, 2024
Copy link

codspeed-hq bot commented Aug 2, 2024

CodSpeed Performance Report

Merging #3576 will degrade performances by 7.96%

Comparing GunseiKPaseri:for-react-refresh (e3e6881) with main (0a80daa)

Summary

⚡ 1 improvements
❌ 1 regressions
✅ 105 untouched benchmarks

⚠️ Please fix the performance issues or acknowledge them on CodSpeed.

Benchmarks breakdown

Benchmark main GunseiKPaseri:for-react-refresh Change
js_analyzer[index_3894593175024091846.js] 37.3 ms 40.5 ms -7.96%
db_17847247775464589309.json[cached] 14.2 ms 12.8 ms +10.52%

@GunseiKPaseri GunseiKPaseri marked this pull request as ready for review August 3, 2024 10:40
///
/// This is necessary to enable the Fast Refresh feature, which improves development efficiency.
/// The determination of whether something is a component depends on naming conventions.
/// Please write components in PascalCase and regular functions in camelCase.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Like useNamingConvention, we can emphasize PascalCase and camelCase and I believe that this is valuable for the user.

ref: https://github.com/biomejs/biome/blob/main/crates/biome_js_analyze/src/lint/style/use_naming_convention.rs#L51-L54

///
/// ### allowExportNames
///
/// If you use a framework that handles HMR of some specific exports, you can use this option to avoid warning for them.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's add some information for HMR like below.

Hot Mudule Replacement(HMR)

}
}

fn is_default<T: Default + Eq>(value: &T) -> bool {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe you can move the is_default function under the UseComponentsOnlyModuleOptions class to make it clear that this is for the field attribute, IMHO.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand that it should be that way. I wrote this based on biome_analyze/CONTRIBUTING.md and use_filenaming_convention.rs as references. Those might need to be revised as well. (For now, I deleted it since it doesn't seem like a crucial item.)

return exported_non_component_ids
.iter()
.filter_map(|id| {
let range = id.identifier.clone().map(|x| x.range())?;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's use as_ref to avoid redundant clone().

if !exported_component_ids.is_empty() {
    return exported_non_component_ids
        .iter()
        .filter_map(|id| {
            id.identifier.as_ref().map(|identifier| {
                UseComponentsOnlyModuleState {
                    error: ErrorType::ExportedNonComponentWithComponent,
                    range: identifier.range(),
                }
            })
        })
        .collect();
}

if let Some(AnyJsExported::AnyJsExpression(AnyJsExpression::JsCallExpression(f))) =
any_exported_item.exported.clone()
{
if let Ok(AnyJsExpression::JsIdentifierExpression(funcname)) = f.callee() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is my preference, but I think naming it fn_name or function_name would be better maybe?

markup! {
"Export Non-Component with components are not allowed."
},
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From the user's point of view, adding this consideration is more useful, I believe.

               .note(markup! {
                    "Consider separating non-component exports into a new file." // Please update the description
                }),

Copy link
Contributor Author

@GunseiKPaseri GunseiKPaseri Aug 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That was originally written in the note section, but I addressed it by splitting the long sentence for clarity.👍

Copy link
Member

@ematipico ematipico left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I left a preliminary review. Can I ask you to update the PR description and explain the business logic you implemented for the rule? Try to be as much as technical as you want. This will help us to review your code, and provide better suggestions to prevent performance regressions

}

impl JsExport {
/// Returns the pair of id and entity of the exported object
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When creating public APIs, we usually add doc tests.

/// Get the inner text of a string not including the quotes
///
/// ## Examples
///
/// ```
/// use biome_js_factory::syntax::{JsDirective, JsSyntaxKind::*};
/// use biome_js_factory::JsSyntaxTreeBuilder;
/// use biome_rowan::AstNode;
/// let mut tree_builder = JsSyntaxTreeBuilder::new();
/// tree_builder.start_node(JS_DIRECTIVE);
/// tree_builder.token(JS_STRING_LITERAL, "\"use strict\"");
/// tree_builder.finish_node();
/// let node = tree_builder.finish();
/// let js_directive = JsDirective::cast(node).unwrap();
/// let text = js_directive.inner_string_text().unwrap();
/// assert_eq!(text, "use strict")
/// ```

Also, think the doc of this function is incorrect. It doesn't return any pairs, it seems. ExportedItem has three fields

Comment on lines 19 to 23
pub struct ExportedItem {
pub identifier: Option<AnyIdentifier>,
pub exported: Option<AnyJsExported>,
pub is_default: bool,
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should document this type and its fields

/// - Static template literals: `foo`
/// - Negative numeric literal: -1
/// - Parenthesized expression: (1)
pub fn is_literal_expression(&self) -> bool {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should add some examples

use serde::{Deserialize, Serialize};

declare_lint_rule! {
/// React components should be separated into different modules.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The first line of the docs is usually a small description of the rule. It's usually added in this table https://biomejs.dev/linter/rules/#accessibility

So the phrase could start with something like "Enforce React components ..." Or "Promotes the use of .."

declare_lint_rule! {
/// React components should be separated into different modules.
///
/// This is necessary to enable the Fast Refresh feature, which improves development efficiency.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should provide a link to Fash Refresh

///
pub UseComponentsOnlyModule {
version: "1.8.0",
name: "useComponentsOnlyModule",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name is weird, but I don't have better ideas 😅

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about useModuleComponentExportsOnly?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is hard...how about useComponentExportOnlyModules (This is just a suggestion).

Comment on lines 156 to 163
pub struct UseComponentsOnlyModuleOptions {
#[serde(default, skip_serializing_if = "is_default")]
allow_constant_export: bool,
#[serde(default, skip_serializing_if = "is_default")]
allow_export_names: Vec<String>,
#[serde(default, skip_serializing_if = "is_default")]
check_js: bool,
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should document these options. These options will be described inside our configuration schema, and users can read its docs.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a way of reducing the number of options? For example is checkJs really useful? or even the two ther options?

Comment on lines 185 to 191
let file_name = ctx.file_path().file_name().and_then(|x| x.to_str());
if let Some(file_name2) = file_name {
if !ctx.options().check_js && !JSX_FILE_EXT.iter().any(|ext| file_name2.ends_with(ext))
{
return vec![];
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have an API that returns the file source, you can use that:

let file_source = ctx.source_type::<JsFileSource>();

JsFileSource has methods to tell you which kind of file you're processing.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Due to relying on PascalCase for component detection, unrelated .js files that export in PascalCase for some reason may cause malfunction. We implement this exception handling to reduce the impact, but is_jsx() may return true for general JavaScript as well.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but is_jsx() may return true for general JavaScript as well.

At the moment, no. Biome doesn't allow JSX inside .js files.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JS files within the test directory are tested as JSX even if they do not contain JSX syntax. Is this specific to the testing environment?

invalid_component_and_constant_js_with_option.js:1:14 lint/nursery/useComponentsOnlyModule ━━━━━━━━━━
! Exporting a non-component with components is not allowed.
> 1 │ export const CONSTANT = 3
│ ^^^^^^^^
2 │ export const Foo = () => {}
3 │
i Fast Refresh only works when a file only exports components.
i Consider separating non-component exports into a new file.
i If it is a component, it may not be following the variable naming conventions.

(The above snapshot shows the expected behavior when checkJS is set to true and targets JavaScript. Normally, I do not want any errors to be generated.)

I do not want to generate errors like the ones mentioned above for .js files. But ctx.source_type::<JsFileSource>().is_jsx() returned true.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have to disregard my previous statement, I am sorry. It seems that our parser enables JSX parsing by default for .js files :(

So yeah, you better keep this check for now. I believe we should break this behaviour in v2.0

// Function that returns a standard React component
const REACT_HOOKS: [&str; 2] = ["memo", "forwardRef"];

fn is_exported_react_component(any_exported_item: &ExportedItem) -> bool {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since ExportedItem is a public API, it makes more sense to expose a function called exports_binding() (just a suggestion, if you have a better name, that's great), and pass the binding name.

@chansuke
Copy link
Member

@GunseiKPaseri
Thank you for your update! I will take a look tonight.

///
pub UseComponentsOnlyModule {
version: "1.8.0",
name: "useComponentsOnlyModule",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is hard...how about useComponentExportOnlyModules (This is just a suggestion).

@GunseiKPaseri GunseiKPaseri changed the title feat(lint): add rule useComponentsOnlyModule feat(lint): add rule useComponentExportOnlyModules Aug 27, 2024
@ematipico ematipico dismissed chansuke’s stale review September 19, 2024 08:05

Took very long, didn't review. They can follow up if they want

@github-actions github-actions bot added the A-Changelog Area: changelog label Sep 19, 2024
@ematipico ematipico merged commit 00760d4 into biomejs:main Sep 19, 2024
12 of 13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
A-Changelog Area: changelog A-CLI Area: CLI A-Diagnostic Area: diagnostocis A-Linter Area: linter A-Parser Area: parser A-Project Area: project L-JavaScript Language: JavaScript and super languages
Projects
None yet
Development

Successfully merging this pull request may close these issues.

📎 Implement eslint-plugin-react-refresh
4 participants