Skip to content

Commit 11ddc05

Browse files
ematipicodyc3
andauthored
feat(lint): add useReactNativePlatformComponents rule and options (#10033)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: dyc3 <1808807+dyc3@users.noreply.github.com>
1 parent acd7841 commit 11ddc05

25 files changed

Lines changed: 822 additions & 0 deletions

File tree

.changeset/vast-phones-argue.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@biomejs/biome": patch
3+
---
4+
5+
Added the nursery rule [`useReactNativePlatformComponents`](https://biomejs.dev/linter/rules/use-react-native-platform-components/) that ensures platform-specific React Native components (e.g. `ProgressBarAndroid`, `ActivityIndicatorIOS`) are only imported in files with a matching platform suffix. It also reports when Android and iOS components are mixed in the same file.
6+
7+
The following code triggers the rule when the file does not have an `.android.js` suffix:
8+
9+
```js
10+
// file.js
11+
import { ProgressBarAndroid } from "react-native";
12+
```

crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/biome_configuration/src/analyzer/linter/rules.rs

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/biome_configuration/src/generated/domain_selector.rs

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/biome_configuration/src/generated/linter_options_check.rs

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/biome_diagnostics_categories/src/categories.rs

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
use biome_analyze::{
2+
Ast, Rule, RuleDiagnostic, RuleDomain, RuleSource, context::RuleContext, declare_lint_rule,
3+
};
4+
use biome_console::markup;
5+
use biome_diagnostics::Severity;
6+
use biome_js_syntax::{
7+
AnyJsBindingPattern, AnyJsImportClause, AnyJsImportLike, AnyJsObjectBindingPatternMember,
8+
JsCallExpression, JsVariableDeclarator,
9+
};
10+
use biome_rowan::{AstNode, TextRange, TokenText};
11+
use biome_rule_options::use_react_native_platform_components::UseReactNativePlatformComponentsOptions;
12+
13+
declare_lint_rule! {
14+
/// Ensure that platform-specific React Native components are only
15+
/// imported in files named for that platform.
16+
///
17+
/// Some React Native components only work on one platform. For example,
18+
/// `ProgressBarAndroid` is Android-only and `ActivityIndicatorIOS` is
19+
/// iOS-only. These components should live in files with a matching
20+
/// platform suffix such as `.android.js` or `.ios.js`, so the React
21+
/// Native bundler can ship the right code to each platform.
22+
///
23+
/// This rule reports an error when a platform-specific component is
24+
/// imported in a file that does not have the matching suffix, or when
25+
/// both Android and iOS components are imported in the same file.
26+
///
27+
/// ## Examples
28+
///
29+
/// ### Invalid
30+
///
31+
/// Importing an Android component in a non-Android file:
32+
///
33+
/// ```js,expect_diagnostic
34+
/// import { ProgressBarAndroid } from "react-native";
35+
/// ```
36+
///
37+
/// Importing an iOS component in a non-iOS file:
38+
///
39+
/// ```js,expect_diagnostic
40+
/// import { ActivityIndicatorIOS } from "react-native";
41+
/// ```
42+
///
43+
/// ### Valid
44+
///
45+
/// ```js
46+
/// import { View } from "react-native";
47+
/// ```
48+
///
49+
/// ## Options
50+
///
51+
/// ### `androidPathPatterns`
52+
///
53+
/// A list of glob patterns to identify Android-specific files.
54+
///
55+
/// Default: `["**/*.android.{js,jsx,ts,tsx}"]`
56+
///
57+
/// In the following example, Android files use `.droid.jsx` as their suffix instead of the default `.android.js`:
58+
///
59+
/// ```json,options
60+
/// {
61+
/// "options": {
62+
/// "androidPathPatterns": ["**/*.droid.jsx"]
63+
/// }
64+
/// }
65+
/// ```
66+
///
67+
/// ```jsx,use_options,file=Button.droid.jsx
68+
/// import { ProgressBarAndroid } from "react-native";
69+
/// ```
70+
///
71+
/// ```jsx,expect_diagnostic,use_options,file=Button.android.jsx
72+
/// import { ProgressBarAndroid } from "react-native";
73+
/// ```
74+
///
75+
/// ### `iosPathPatterns`
76+
///
77+
/// A list of glob patterns to identify iOS-specific files.
78+
///
79+
/// Default: `["**/*.ios.{js,jsx,ts,tsx}"]`
80+
///
81+
/// In the following example, iOS files use `.apple.jsx` as their suffix instead of the default `.ios.js`:
82+
///
83+
/// ```json,options
84+
/// {
85+
/// "options": {
86+
/// "iosPathPatterns": ["**/*.apple.jsx"]
87+
/// }
88+
/// }
89+
/// ```
90+
///
91+
/// ```jsx,use_options,file=Button.apple.jsx
92+
/// import { ActivityIndicatorIOS } from "react-native";
93+
/// ```
94+
///
95+
/// ```jsx,expect_diagnostic,use_options,file=Button.ios.jsx
96+
/// import { ActivityIndicatorIOS } from "react-native";
97+
/// ```
98+
///
99+
pub UseReactNativePlatformComponents {
100+
version: "next",
101+
name: "useReactNativePlatformComponents",
102+
language: "js",
103+
sources: &[RuleSource::EslintReactNative("split-platform-components").inspired()],
104+
domains: &[RuleDomain::ReactNative],
105+
recommended: true,
106+
severity: Severity::Error,
107+
}
108+
}
109+
110+
impl Rule for UseReactNativePlatformComponents {
111+
type Query = Ast<AnyJsImportLike>;
112+
type State = RuleState;
113+
type Signals = Vec<Self::State>;
114+
type Options = UseReactNativePlatformComponentsOptions;
115+
116+
fn run(ctx: &RuleContext<Self>) -> Self::Signals {
117+
let node = ctx.query();
118+
119+
let module_name = node.inner_string_text();
120+
if module_name.as_ref().map(|t| t.text()) != Some("react-native") {
121+
return vec![];
122+
}
123+
124+
let component_names = collect_imported_names(node);
125+
if component_names.is_empty() {
126+
return vec![];
127+
}
128+
129+
let file_path = ctx.file_path().as_str();
130+
let options = ctx.options();
131+
132+
let is_android_file = options
133+
.android_path_patterns()
134+
.iter()
135+
.any(|glob| glob.is_match(file_path));
136+
let is_ios_file = !is_android_file
137+
&& options
138+
.ios_path_patterns()
139+
.iter()
140+
.any(|glob| glob.is_match(file_path));
141+
142+
let mut has_android = false;
143+
let mut has_ios = false;
144+
for (_, name_text) in &component_names {
145+
let text = name_text.text();
146+
has_android |= text.contains("Android");
147+
has_ios |= text.contains("IOS");
148+
}
149+
let has_conflict = has_android && has_ios;
150+
151+
let mut results: Vec<RuleState> = Vec::new();
152+
for (range, name_text) in component_names {
153+
let text = name_text.text();
154+
if text.contains("Android") && !is_android_file {
155+
results.push(RuleState {
156+
kind: PlatformKind::Android,
157+
range,
158+
name: name_text,
159+
has_conflict,
160+
});
161+
} else if text.contains("IOS") && !is_ios_file {
162+
results.push(RuleState {
163+
kind: PlatformKind::Ios,
164+
range,
165+
name: name_text,
166+
has_conflict,
167+
});
168+
}
169+
}
170+
171+
results
172+
}
173+
174+
fn diagnostic(_ctx: &RuleContext<Self>, state: &Self::State) -> Option<RuleDiagnostic> {
175+
let name = state.name.text();
176+
let diagnostic = if state.has_conflict {
177+
RuleDiagnostic::new(
178+
rule_category!(),
179+
state.range,
180+
markup! {
181+
"iOS and Android components cannot be mixed in the same file."
182+
},
183+
)
184+
.note(markup! {
185+
<Emphasis>{name}</Emphasis>" is a platform-specific component."
186+
})
187+
.note(markup! {
188+
"Split iOS and Android components into separate platform-specific files."
189+
})
190+
} else {
191+
match state.kind {
192+
PlatformKind::Android => RuleDiagnostic::new(
193+
rule_category!(),
194+
state.range,
195+
markup! {
196+
"Android component "<Emphasis>{name}</Emphasis>" is used outside of an Android-specific file."
197+
},
198+
)
199+
.note(markup! {
200+
"Platform-specific components produce incorrect bundles when imported in shared files."
201+
})
202+
.note(markup! {
203+
"Move this import to a file with an Android-specific suffix (e.g. "<Emphasis>".android.js"</Emphasis>")."
204+
}),
205+
PlatformKind::Ios => RuleDiagnostic::new(
206+
rule_category!(),
207+
state.range,
208+
markup! {
209+
"iOS component "<Emphasis>{name}</Emphasis>" is used outside of an iOS-specific file."
210+
},
211+
)
212+
.note(markup! {
213+
"Platform-specific components produce incorrect bundles when imported in shared files."
214+
})
215+
.note(markup! {
216+
"Move this import to a file with an iOS-specific suffix (e.g. "<Emphasis>".ios.js"</Emphasis>")."
217+
}),
218+
}
219+
};
220+
221+
Some(diagnostic)
222+
}
223+
}
224+
225+
enum PlatformKind {
226+
Android,
227+
Ios,
228+
}
229+
230+
pub struct RuleState {
231+
kind: PlatformKind,
232+
range: TextRange,
233+
name: TokenText,
234+
has_conflict: bool,
235+
}
236+
237+
/// Collects the names of components imported from `react-native`.
238+
///
239+
/// Handles both ES module imports (`import { X } from 'react-native'`)
240+
/// and CommonJS requires (`const { X } = require('react-native')`).
241+
fn collect_imported_names(node: &AnyJsImportLike) -> Vec<(TextRange, TokenText)> {
242+
match node {
243+
AnyJsImportLike::JsModuleSource(source) => {
244+
let clause = source.parent::<AnyJsImportClause>();
245+
let Some(named_specifiers) = clause.and_then(|c| c.named_specifiers()) else {
246+
return Vec::new();
247+
};
248+
named_specifiers
249+
.specifiers()
250+
.into_iter()
251+
.flatten()
252+
.filter_map(|spec| {
253+
let token = spec.imported_name()?;
254+
Some((spec.range(), token.token_text_trimmed()))
255+
})
256+
.collect()
257+
}
258+
AnyJsImportLike::JsCallExpression(call) => collect_require_destructured_names(call),
259+
AnyJsImportLike::JsImportCallExpression(_) => Vec::new(),
260+
}
261+
}
262+
263+
/// Extracts destructured property names from `const { X } = require('react-native')`.
264+
fn collect_require_destructured_names(call: &JsCallExpression) -> Vec<(TextRange, TokenText)> {
265+
let Some(declarator) = call
266+
.syntax()
267+
.grand_parent()
268+
.and_then(JsVariableDeclarator::cast)
269+
else {
270+
return Vec::new();
271+
};
272+
273+
let Ok(AnyJsBindingPattern::JsObjectBindingPattern(pattern)) = declarator.id() else {
274+
return Vec::new();
275+
};
276+
277+
// This pattern doesn't handle nested bindings on purpose, as it's unlikely to happen.
278+
// However, if that happens, this code needs to be updated to handle it.
279+
pattern
280+
.properties()
281+
.into_iter()
282+
.flatten()
283+
.filter_map(|member| match &member {
284+
AnyJsObjectBindingPatternMember::JsObjectBindingPatternShorthandProperty(prop) => {
285+
let binding = prop.identifier().ok()?;
286+
let ident = binding.as_js_identifier_binding()?;
287+
let token = ident.name_token().ok()?;
288+
Some((prop.range(), token.token_text_trimmed()))
289+
}
290+
AnyJsObjectBindingPatternMember::JsObjectBindingPatternProperty(prop) => {
291+
let member = prop.member().ok()?;
292+
let ident = member.as_js_literal_member_name()?;
293+
let token = ident.value().ok()?;
294+
Some((prop.range(), token.token_text_trimmed()))
295+
}
296+
_ => None,
297+
})
298+
.collect()
299+
}

0 commit comments

Comments
 (0)