feat(linter): add allow and allowPattern options to react/capitalized-calls

Original PR: #25960
This commit is contained in:
Christian Vuerings authored and Boshen committed 2026-10-29 15:21:32 +08:00
1 parent d24b61c108
commit 419c1b0fd0
14 files changed
+249 -15

No files matched your search

Generated
+1
View File
@@ -2524,6 +2524,7 @@ dependencies = [
"cow-utils",
"indexmap",
"insta",
"lazy-regex",
"oxc_allocator",
"oxc_ast",
"oxc_ast_visit",
+17 -1
View File
@@ -1323,7 +1323,7 @@ export interface DummyRuleMap {
"react-perf/jsx-no-new-function-as-prop"?: RuleNoConfig | [AllowWarnDeny, ReactPerfConfig];
"react-perf/jsx-no-new-object-as-prop"?: RuleNoConfig | [AllowWarnDeny, ReactPerfConfig];
"react/button-has-type"?: RuleNoConfig | [AllowWarnDeny, ButtonHasType];
"react/capitalized-calls"?: RuleNoConfig;
"react/capitalized-calls"?: RuleNoConfig | [AllowWarnDeny, CapitalizedCallsConfig];
"react/checked-requires-onchange-or-readonly"?: RuleNoConfig | [AllowWarnDeny, CheckedRequiresOnchangeOrReadonly];
"react/display-name"?: RuleNoConfig | [AllowWarnDeny, DisplayNameConfig];
"react/error-boundaries"?: RuleNoConfig;
@@ -1997,6 +1997,7 @@ export interface DummyRuleMap {
| [AllowWarnDeny, RadixType]
| [AllowWarnDeny, ReactPerfConfig]
| [AllowWarnDeny, ButtonHasType]
| [AllowWarnDeny, CapitalizedCallsConfig]
| [AllowWarnDeny, CheckedRequiresOnchangeOrReadonly]
| [AllowWarnDeny, DisplayNameConfig]
| [AllowWarnDeny, ExhaustiveDepsConfig]
@@ -4971,6 +4972,21 @@ export interface ButtonHasType {
*/
submit?: boolean;
}
export interface CapitalizedCallsConfig {
/**
* Exact names of capitalized functions that may be called directly.
* Forwarded to the React Compiler's `validateNoCapitalizedCalls`
* environment option.
*/
allow?: string[];
/**
* A regex pattern; capitalized functions whose name matches may be called
* directly, checked alongside `allow`. Useful when a codebase has a
* naming convention for capitalized non-component factories, such as
* generated event or schema builders (e.g. `"Event$"`).
*/
allowPattern?: string;
}
export interface CheckedRequiresOnchangeOrReadonly {
/**
* Ignore the restriction that `checked` and `defaultChecked` should not be used together.
@@ -0,0 +1,7 @@
{
"categories": { "correctness": "off" },
"plugins": ["react"],
"rules": {
"react/capitalized-calls": ["error", { "allow": ["Child"], "allowPattern": "Event$" }]
}
}
@@ -0,0 +1,30 @@
import Child from "./Child";
import Other from "./Other";
import { ButtonClickEvent, EventChild } from "./events";
import myModule from "./MyModule";
// Allowed by `allow`.
export function AllowedName() {
return <div>{Child()}</div>;
}
// Allowed by `allowPattern`.
export function AllowedPattern() {
const event = ButtonClickEvent();
return <button onClick={() => console.log(event)} />;
}
// Not in `allow` and does not match `allowPattern`.
export function NotAllowed() {
return <div>{Other()}</div>;
}
// `allowPattern` is a regex, so `Event$` only matches names ending in `Event`.
export function PatternDoesNotMatch() {
return <div>{EventChild()}</div>;
}
// Like the compiler's `validateNoCapitalizedCalls`, the allowlist only applies to global loads.
export function MethodCall() {
return <div>{myModule.Child()}</div>;
}
@@ -0,0 +1,48 @@
# Exit code
1
# stdout
```
x react(capitalized-calls): Capitalized function called without JSX
,-[files/index.jsx:19:16]
18 | export function NotAllowed() {
19 | return <div>{Other()}</div>;
: ^^|^^
: `-- `Other` may be a component
20 | }
`----
help: Render `Other` with JSX if it is a component; otherwise rename it to start with a lowercase letter or allowlist it in the compiler configuration
note: `Other` is treated as a component because it begins with an uppercase letter; React Compiler skipped optimizing this component or hook. Additional guidance: https://react.dev/reference/
eslint-plugin-react-hooks/lints/static-components
x react(capitalized-calls): Capitalized function called without JSX
,-[files/index.jsx:24:16]
23 | export function PatternDoesNotMatch() {
24 | return <div>{EventChild()}</div>;
: ^^^^^|^^^^
: `-- `EventChild` may be a component
25 | }
`----
help: Render `EventChild` with JSX if it is a component; otherwise rename it to start with a lowercase letter or allowlist it in the compiler configuration
note: `EventChild` is treated as a component because it begins with an uppercase letter; React Compiler skipped optimizing this component or hook. Additional guidance: https://react.dev/
reference/eslint-plugin-react-hooks/lints/static-components
x react(capitalized-calls): Capitalized function called without JSX
,-[files/index.jsx:29:25]
28 | export function MethodCall() {
29 | return <div>{myModule.Child()}</div>;
: ^^|^^
: `-- `Child` may be a component
30 | }
`----
help: Render `Child` with JSX if it is a component; otherwise rename it to start with a lowercase letter or allowlist it in the compiler configuration
note: `Child` is treated as a component because it begins with an uppercase letter; React Compiler skipped optimizing this component or hook. Additional guidance: https://react.dev/reference/
eslint-plugin-react-hooks/lints/static-components
Found 0 warnings and 3 errors.
Finished in Xms on 1 file with 1 rules using X threads.
```
# stderr
```
```
+18 -1
View File
@@ -25,7 +25,7 @@ use crate::{
module_record::ModuleRecord,
options::LintOptions,
rules::RuleEnum,
utils::ReactCompilerResults,
utils::{ReactCompilerEnvOptions, ReactCompilerResults},
};
#[cfg(not(test))]
@@ -185,6 +185,9 @@ pub struct ContextHost<'a> {
/// every rule in the React Compiler family (`react/hooks`, `react/refs`, …).
/// Stays empty until the first such rule runs on this file.
pub(super) react_compiler_results: OnceCell<ReactCompilerResults>,
/// Config-derived environment additions for that shared run (from the
/// `react/capitalized-calls` rule's options).
react_compiler_env_options: ReactCompilerEnvOptions,
}
impl std::fmt::Debug for ContextHost<'_> {
@@ -225,10 +228,24 @@ impl<'a> ContextHost<'a> {
frameworks: options.framework_hints,
with_ignore_fixes: options.with_ignore_fixes,
react_compiler_results: OnceCell::new(),
react_compiler_env_options: ReactCompilerEnvOptions::default(),
}
.sniff_for_frameworks()
}
/// Set the config-derived environment additions for the shared React
/// Compiler run. Must happen before the first React Compiler family rule
/// runs on this file.
pub fn with_react_compiler_env_options(mut self, env_options: ReactCompilerEnvOptions) -> Self {
self.react_compiler_env_options = env_options;
self
}
/// Config-derived environment additions for the shared React Compiler run.
pub(crate) fn react_compiler_env_options(&self) -> &ReactCompilerEnvOptions {
&self.react_compiler_env_options
}
/// The current [`ContextSubHost`]
pub fn current_sub_host(&self) -> &ContextSubHost<'a> {
&self.sub_hosts[self.current_sub_host_index.get()]
+3
View File
@@ -11853,6 +11853,9 @@ impl RuleEnum {
Self::ReactButtonHasType(_) => {
Ok(Self::ReactButtonHasType(ReactButtonHasType::from_configuration(value)?))
}
Self::ReactCapitalizedCalls(_) => {
Ok(Self::ReactCapitalizedCalls(ReactCapitalizedCalls::from_configuration(value)?))
}
Self::ReactCheckedRequiresOnchangeOrReadonly(_) => {
Ok(Self::ReactCheckedRequiresOnchangeOrReadonly(
ReactCheckedRequiresOnchangeOrReadonly::from_configuration(value)?,
+14 -2
View File
@@ -346,8 +346,20 @@ impl Linter {
let ResolvedLinterState { rules, config, external_rules } = self.config.resolve(path);
let mut timing_recorder = TIMINGS.then(|| RuleTimingRecorder::with_capacity(rules.len()));
let mut ctx_host =
Rc::new(ContextHost::new(path, context_sub_hosts, allocator, self.options, config));
// The shared React Compiler run takes its environment additions from the
// `react/capitalized-calls` rule's resolved options.
let react_compiler_env_options = rules
.iter()
.find_map(|(rule, _)| match rule {
RuleEnum::ReactCapitalizedCalls(rule) => Some(rule.react_compiler_env_options()),
_ => None,
})
.unwrap_or_default();
let mut ctx_host = Rc::new(
ContextHost::new(path, context_sub_hosts, allocator, self.options, config)
.with_react_compiler_env_options(react_compiler_env_options),
);
#[cfg(debug_assertions)]
let mut current_diagnostic_index = 0;
@@ -1,13 +1,34 @@
use lazy_regex::Regex;
use oxc_react_compiler::ErrorCategory;
use schemars::JsonSchema;
use serde::Deserialize;
use crate::{
context::{ContextHost, LintContext},
rule::Rule,
utils::{run_react_compiler_rule, should_run_react_compiler},
rule::{DefaultRuleConfig, Rule},
utils::{
ReactCompilerEnvOptions, deserialize_regex_option, run_react_compiler_rule,
should_run_react_compiler,
},
};
#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", default, deny_unknown_fields)]
pub struct CapitalizedCallsConfig {
/// Exact names of capitalized functions that may be called directly.
/// Forwarded to the React Compiler's `validateNoCapitalizedCalls`
/// environment option.
allow: Vec<String>,
/// A regex pattern; capitalized functions whose name matches may be called
/// directly, checked alongside `allow`. Useful when a codebase has a
/// naming convention for capitalized non-component factories, such as
/// generated event or schema builders (e.g. `"Event$"`).
#[serde(deserialize_with = "deserialize_regex_option")]
allow_pattern: Option<Regex>,
}
#[derive(Debug, Default, Clone)]
pub struct CapitalizedCalls;
pub struct CapitalizedCalls(Box<CapitalizedCallsConfig>);
declare_react_compiler_lint!(
/// ### What it does
@@ -44,11 +65,17 @@ declare_react_compiler_lint!(
CapitalizedCalls,
react,
suspicious,
config = CapitalizedCallsConfig,
version = "1.79.0",
short_description = "Disallow calling capitalized functions and methods instead of using JSX.",
);
impl Rule for CapitalizedCalls {
fn from_configuration(value: serde_json::Value) -> Result<Self, serde_json::error::Error> {
DefaultRuleConfig::<CapitalizedCallsConfig>::from_value(value)
.map(|config| Self(Box::new(config.into_inner())))
}
fn run_once(&self, ctx: &LintContext) {
run_react_compiler_rule(ctx, ErrorCategory::CapitalizedCalls);
}
@@ -58,6 +85,17 @@ impl Rule for CapitalizedCalls {
}
}
impl CapitalizedCalls {
/// The environment additions this rule's options contribute to the shared
/// React Compiler run.
pub(crate) fn react_compiler_env_options(&self) -> ReactCompilerEnvOptions {
ReactCompilerEnvOptions {
capitalized_calls_allow: self.0.allow.clone(),
capitalized_calls_allow_pattern: self.0.allow_pattern.clone(),
}
}
}
#[test]
fn test() {
use crate::tester::Tester;
+24 -7
View File
@@ -1,5 +1,6 @@
use std::borrow::Cow;
use lazy_regex::Regex;
use oxc_diagnostics::OxcCode;
use oxc_react_compiler::{
CompilerOutputMode, EnvironmentConfig, ErrorCategory, ExhaustiveEffectDepsMode, LintDiagnostic,
@@ -11,16 +12,29 @@ use crate::{
loader::LINT_PARTIAL_LOADER_EXTENSIONS,
};
/// Environment additions to the shared React Compiler run that come from rule
/// configuration, extracted once per resolved config in `Linter::run` (from the
/// `react/capitalized-calls` rule's options). Config-derived rather than
/// per-run rule state, so the single shared run stays valid for any
/// combination of enabled rules.
#[derive(Debug, Default, Clone)]
pub struct ReactCompilerEnvOptions {
/// Extra allowed names for `validateNoCapitalizedCalls`.
pub capitalized_calls_allow: Vec<String>,
/// Allowed-name pattern for `validateNoCapitalizedCalls`.
pub capitalized_calls_allow_pattern: Option<Regex>,
}
/// The compiler options the React Compiler family of rules lints with — `lint`
/// output mode plus validations that are off by default in the compiler.
/// Mirrors `COMPILER_OPTIONS` in `eslint-plugin-react-hooks`'s
/// `src/shared/RunReactCompiler.ts`.
///
/// The options are a fixed superset shared by every rule in the family: which
/// rules are enabled only routes categories to reporters, it never changes
/// what the compiler analyzes. That keeps the single shared run valid for any
/// combination of enabled rules.
fn react_compiler_plugin_options() -> PluginOptions {
/// The options are a superset shared by every rule in the family: which rules
/// are enabled only routes categories to reporters, and the only per-config
/// variation is [`ReactCompilerEnvOptions`]. That keeps the single shared run
/// valid for any combination of enabled rules.
fn react_compiler_plugin_options(env_options: &ReactCompilerEnvOptions) -> PluginOptions {
PluginOptions {
output_mode: Some(CompilerOutputMode::Lint),
// Oxlint does not parse Flow files.
@@ -44,7 +58,10 @@ fn react_compiler_plugin_options() -> PluginOptions {
validate_static_components: true,
validate_no_freezing_known_mutable_functions: true,
validate_no_void_use_memo: true,
validate_no_capitalized_calls: Some(vec![]),
validate_no_capitalized_calls: Some(env_options.capitalized_calls_allow.clone()),
validate_no_capitalized_calls_pattern: env_options
.capitalized_calls_allow_pattern
.clone(),
validate_hooks_usage: true,
validate_no_derived_computations_in_effects: true,
validate_exhaustive_memoization_dependencies: true,
@@ -82,7 +99,7 @@ pub fn build_react_compiler_results(host: &ContextHost) -> ReactCompilerResults
program,
semantic,
host.allocator(),
react_compiler_plugin_options(),
react_compiler_plugin_options(host.react_compiler_env_options()),
);
ReactCompilerResults { diagnostics: result.diagnostics }
+1
View File
@@ -28,6 +28,7 @@ include = ["src/**/*", "examples/**/*", "LICENSE", "README.md", "CHANGELOG.md"]
doctest = false
[dependencies]
lazy-regex = { workspace = true }
oxc_allocator = { workspace = true }
oxc_ast = { workspace = true }
oxc_ast_visit = { workspace = true }
@@ -8,6 +8,7 @@
//! Contains feature flags and custom hook definitions that control compiler behavior.
use crate::react_compiler_utils::FxIndexMap;
use lazy_regex::Regex;
use rustc_hash::FxHashMap;
use crate::react_compiler_hir::Effect;
@@ -89,6 +90,9 @@ pub struct EnvironmentConfig {
pub validate_no_jsx_in_try_statements: bool,
pub validate_static_components: bool,
pub validate_no_capitalized_calls: Option<Vec<String>>,
/// Oxlint extension to `validate_no_capitalized_calls`: names matching the
/// pattern are allowed, in addition to the exact entries above.
pub validate_no_capitalized_calls_pattern: Option<Regex>,
pub validate_blocklisted_imports: Option<Vec<String>>,
pub validate_source_locations: bool,
pub validate_no_impure_functions_in_render: bool,
@@ -141,6 +145,7 @@ impl Default for EnvironmentConfig {
validate_no_jsx_in_try_statements: false,
validate_static_components: false,
validate_no_capitalized_calls: None,
validate_no_capitalized_calls_pattern: None,
validate_blocklisted_imports: None,
validate_source_locations: false,
validate_no_impure_functions_in_render: false,
@@ -23,6 +23,7 @@ pub fn validate_no_capitalized_calls(
allow_list.insert(entry.clone());
}
}
let allow_pattern = env.config.validate_no_capitalized_calls_pattern.clone();
let mut capital_load_globals: FxHashMap<IdentifierId, Ident> = FxHashMap::default();
let mut capitalized_properties: FxHashMap<IdentifierId, (Ident, Option<Span>)> =
@@ -42,6 +43,7 @@ pub fn validate_no_capitalized_calls(
// We don't want to flag CONSTANTS()
&& name.as_str() != name.cow_to_uppercase()
&& !allow_list.contains(name.as_str())
&& !allow_pattern.as_ref().is_some_and(|pattern| pattern.is_match(name.as_str()))
{
capital_load_globals.insert(lvalue_id, name);
}
+38 -1
View File
@@ -1173,6 +1173,26 @@
},
"markdownDescription": "The rule takes a single option - an array of possible callback names - which may include object methods. The default callback names are `callback`, `cb`, `next`."
},
"CapitalizedCallsConfig": {
"type": "object",
"properties": {
"allow": {
"description": "Exact names of capitalized functions that may be called directly.\nForwarded to the React Compiler's `validateNoCapitalizedCalls`\nenvironment option.",
"default": [],
"type": "array",
"items": {
"type": "string"
},
"markdownDescription": "Exact names of capitalized functions that may be called directly.\nForwarded to the React Compiler's `validateNoCapitalizedCalls`\nenvironment option."
},
"allowPattern": {
"description": "A regex pattern; capitalized functions whose name matches may be called\ndirectly, checked alongside `allow`. Useful when a codebase has a\nnaming convention for capitalized non-component factories, such as\ngenerated event or schema builders (e.g. `\"Event$\"`).",
"type": "string",
"markdownDescription": "A regex pattern; capitalized functions whose name matches may be called\ndirectly, checked alongside `allow`. Useful when a codebase has a\nnaming convention for capitalized non-component factories, such as\ngenerated event or schema builders (e.g. `\"Event$\"`)."
}
},
"additionalProperties": false
},
"CapitalizedCommentsOptions": {
"description": "Configuration for the capitalized-comments rule.\n\nThe first element specifies whether comments should `\"always\"` or `\"never\"`\nbegin with a capital letter. The second element is an optional object\ncontaining additional options.",
"type": "array",
@@ -6580,7 +6600,24 @@
]
},
"react/capitalized-calls": {
"$ref": "#/definitions/RuleNoConfig"
"anyOf": [
{
"$ref": "#/definitions/RuleNoConfig"
},
{
"type": "array",
"items": [
{
"$ref": "#/definitions/AllowWarnDeny"
},
{
"$ref": "#/definitions/CapitalizedCallsConfig"
}
],
"maxItems": 2,
"minItems": 2
}
]
},
"react/checked-requires-onchange-or-readonly": {
"anyOf": [