From fd68928a888b4123eb55d2e6d1ee32d28ecc991c Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Mon, 26 Oct 2026 20:13:52 +0800 Subject: [PATCH] perf(react-compiler): skip passes lint does not need Original PR: #26657 (React Compiler part) --- .../react_globals_prefilter/.oxlintrc.json | 5 ++ .../react_globals_prefilter/files/helper.js | 7 ++ .../react_globals_prefilter/files/memo.jsx | 7 ++ .../react_globals_prefilter/files/provider.ts | 10 +++ .../react_globals_prefilter/files/use-foo.js | 9 +++ .../react_globals_prefilter/output.snap.md | 48 +++++++++++++ .../.oxlintrc.json | 5 ++ .../files/index.jsx | 9 +++ .../output.snap.md | 22 ++++++ crates/oxc_linter/src/lib.rs | 8 ++- .../src/rules/react/capitalized_calls.rs | 1 + crates/oxc_linter/src/utils/react_compiler.rs | 10 ++- .../lint-effect-deps-without-manual-memo.js | 15 ++++ ...int-effect-hook-destructured-from-react.js | 12 ++++ ...reports-todo-from-reactive-scope-passes.js | 14 ++++ crates/oxc_react_compiler/src/options.rs | 6 ++ .../src/react_compiler/entrypoint/pipeline.rs | 72 ++++++++++++++++++- .../src/react_compiler/entrypoint/program.rs | 56 ++++++++------- crates/oxc_react_compiler/tests/snapshot.rs | 1 + ..._lint-effect-deps-without-manual-memo.snap | 22 ++++++ ...t-effect-hook-destructured-from-react.snap | 20 ++++++ ...ports-todo-from-reactive-scope-passes.snap | 21 ++++++ 22 files changed, 349 insertions(+), 31 deletions(-) create mode 100644 apps/oxlint/test/fixtures/react_globals_prefilter/.oxlintrc.json create mode 100644 apps/oxlint/test/fixtures/react_globals_prefilter/files/helper.js create mode 100644 apps/oxlint/test/fixtures/react_globals_prefilter/files/memo.jsx create mode 100644 apps/oxlint/test/fixtures/react_globals_prefilter/files/provider.ts create mode 100644 apps/oxlint/test/fixtures/react_globals_prefilter/files/use-foo.js create mode 100644 apps/oxlint/test/fixtures/react_globals_prefilter/output.snap.md create mode 100644 apps/oxlint/test/fixtures/react_todo_reactive_scope_passes/.oxlintrc.json create mode 100644 apps/oxlint/test/fixtures/react_todo_reactive_scope_passes/files/index.jsx create mode 100644 apps/oxlint/test/fixtures/react_todo_reactive_scope_passes/output.snap.md create mode 100644 crates/oxc_react_compiler/fixtures/exhaustive-deps/lint-effect-deps-without-manual-memo.js create mode 100644 crates/oxc_react_compiler/fixtures/exhaustive-deps/lint-effect-hook-destructured-from-react.js create mode 100644 crates/oxc_react_compiler/fixtures/lint-reports-todo-from-reactive-scope-passes.js create mode 100644 crates/oxc_react_compiler/tests/snapshots/exhaustive-deps__lint-effect-deps-without-manual-memo.snap create mode 100644 crates/oxc_react_compiler/tests/snapshots/exhaustive-deps__lint-effect-hook-destructured-from-react.snap create mode 100644 crates/oxc_react_compiler/tests/snapshots/lint-reports-todo-from-reactive-scope-passes.snap diff --git a/apps/oxlint/test/fixtures/react_globals_prefilter/.oxlintrc.json b/apps/oxlint/test/fixtures/react_globals_prefilter/.oxlintrc.json new file mode 100644 index 0000000000..49a826c71b --- /dev/null +++ b/apps/oxlint/test/fixtures/react_globals_prefilter/.oxlintrc.json @@ -0,0 +1,5 @@ +{ + "categories": { "correctness": "off" }, + "plugins": ["react"], + "rules": { "react/globals": "error" } +} diff --git a/apps/oxlint/test/fixtures/react_globals_prefilter/files/helper.js b/apps/oxlint/test/fixtures/react_globals_prefilter/files/helper.js new file mode 100644 index 0000000000..1af7a012c7 --- /dev/null +++ b/apps/oxlint/test/fixtures/react_globals_prefilter/files/helper.js @@ -0,0 +1,7 @@ +let someGlobal = false; + +// Not a component or hook, so the compiler does not lint it. +export function helper() { + someGlobal = true; + return someGlobal; +} diff --git a/apps/oxlint/test/fixtures/react_globals_prefilter/files/memo.jsx b/apps/oxlint/test/fixtures/react_globals_prefilter/files/memo.jsx new file mode 100644 index 0000000000..9d23fd2dcb --- /dev/null +++ b/apps/oxlint/test/fixtures/react_globals_prefilter/files/memo.jsx @@ -0,0 +1,7 @@ +let someGlobal = false; + +// An anonymous component wrapped in `React.memo` is linted. +export const Component = React.memo(function (props) { + someGlobal = true; + return
{props.text}
; +}); diff --git a/apps/oxlint/test/fixtures/react_globals_prefilter/files/provider.ts b/apps/oxlint/test/fixtures/react_globals_prefilter/files/provider.ts new file mode 100644 index 0000000000..1af7dae7c6 --- /dev/null +++ b/apps/oxlint/test/fixtures/react_globals_prefilter/files/provider.ts @@ -0,0 +1,10 @@ +import { createElement, useState } from "react"; + +let someGlobal = false; + +// A PascalCase component that calls hooks is linted in a non-JSX file too. +export function Provider(props) { + const [value] = useState(0); + someGlobal = true; + return createElement("div", null, value, props.children); +} diff --git a/apps/oxlint/test/fixtures/react_globals_prefilter/files/use-foo.js b/apps/oxlint/test/fixtures/react_globals_prefilter/files/use-foo.js new file mode 100644 index 0000000000..810a6d9a23 --- /dev/null +++ b/apps/oxlint/test/fixtures/react_globals_prefilter/files/use-foo.js @@ -0,0 +1,9 @@ +import { useState } from "react"; + +let someGlobal = false; + +// A hook is linted even without JSX. +export function useFoo() { + someGlobal = true; + return useState(0); +} diff --git a/apps/oxlint/test/fixtures/react_globals_prefilter/output.snap.md b/apps/oxlint/test/fixtures/react_globals_prefilter/output.snap.md new file mode 100644 index 0000000000..9a5f12ec6c --- /dev/null +++ b/apps/oxlint/test/fixtures/react_globals_prefilter/output.snap.md @@ -0,0 +1,48 @@ +# Exit code +1 + +# stdout +``` + x react(globals): Cannot reassign variables declared outside of the component/hook + ,-[files/memo.jsx:5:3] + 4 | export const Component = React.memo(function (props) { + 5 | someGlobal = true; + : ^^^^^|^^^^ + : `-- `someGlobal` cannot be reassigned + 6 | return
{props.text}
; + `---- + help: Variable `someGlobal` is declared outside of the component/hook. Reassigning this value during render is a side effect which can cause unpredictable behavior. If this variable is used in + rendering, use useState instead. Otherwise, update it in an effect + note: React Compiler skipped optimizing this component or hook. Additional guidance: https://react.dev/reference/eslint-plugin-react-hooks/lints/globals + + x react(globals): Cannot reassign variables declared outside of the component/hook + ,-[files/provider.ts:8:3] + 7 | const [value] = useState(0); + 8 | someGlobal = true; + : ^^^^^|^^^^ + : `-- `someGlobal` cannot be reassigned + 9 | return createElement("div", null, value, props.children); + `---- + help: Variable `someGlobal` is declared outside of the component/hook. Reassigning this value during render is a side effect which can cause unpredictable behavior. If this variable is used in + rendering, use useState instead. Otherwise, update it in an effect + note: React Compiler skipped optimizing this component or hook. Additional guidance: https://react.dev/reference/eslint-plugin-react-hooks/lints/globals + + x react(globals): Cannot reassign variables declared outside of the component/hook + ,-[files/use-foo.js:7:3] + 6 | export function useFoo() { + 7 | someGlobal = true; + : ^^^^^|^^^^ + : `-- `someGlobal` cannot be reassigned + 8 | return useState(0); + `---- + help: Variable `someGlobal` is declared outside of the component/hook. Reassigning this value during render is a side effect which can cause unpredictable behavior. If this variable is used in + rendering, use useState instead. Otherwise, update it in an effect + note: React Compiler skipped optimizing this component or hook. Additional guidance: https://react.dev/reference/eslint-plugin-react-hooks/lints/globals + +Found 0 warnings and 3 errors. +Finished in Xms on 4 files with 1 rules using X threads. +``` + +# stderr +``` +``` diff --git a/apps/oxlint/test/fixtures/react_todo_reactive_scope_passes/.oxlintrc.json b/apps/oxlint/test/fixtures/react_todo_reactive_scope_passes/.oxlintrc.json new file mode 100644 index 0000000000..5f5e77e7db --- /dev/null +++ b/apps/oxlint/test/fixtures/react_todo_reactive_scope_passes/.oxlintrc.json @@ -0,0 +1,5 @@ +{ + "categories": { "correctness": "off" }, + "plugins": ["react"], + "rules": { "react/todo": "error" } +} diff --git a/apps/oxlint/test/fixtures/react_todo_reactive_scope_passes/files/index.jsx b/apps/oxlint/test/fixtures/react_todo_reactive_scope_passes/files/index.jsx new file mode 100644 index 0000000000..040e3592b3 --- /dev/null +++ b/apps/oxlint/test/fixtures/react_todo_reactive_scope_passes/files/index.jsx @@ -0,0 +1,9 @@ +export function Foo() { + function foo() { + return bar(); + } + function bar() { + return 42; + } + return
; +} diff --git a/apps/oxlint/test/fixtures/react_todo_reactive_scope_passes/output.snap.md b/apps/oxlint/test/fixtures/react_todo_reactive_scope_passes/output.snap.md new file mode 100644 index 0000000000..95f016c232 --- /dev/null +++ b/apps/oxlint/test/fixtures/react_todo_reactive_scope_passes/output.snap.md @@ -0,0 +1,22 @@ +# Exit code +1 + +# stdout +``` + x react(todo): [PruneHoistedContexts] Rewrite hoisted function references + ,-[files/index.jsx:3:12] + 2 | function foo() { + 3 | return bar(); + : ^^^ + 4 | } + `---- + help: Rewrite the highlighted code using syntax supported by React Compiler + note: React Compiler skipped optimizing this component or hook. Additional guidance: https://react.dev/reference/eslint-plugin-react-hooks/lints/unsupported-syntax + +Found 0 warnings and 1 error. +Finished in Xms on 1 file with 1 rules using X threads. +``` + +# stderr +``` +``` diff --git a/crates/oxc_linter/src/lib.rs b/crates/oxc_linter/src/lib.rs index f74a966bc7..591ec37c19 100644 --- a/crates/oxc_linter/src/lib.rs +++ b/crates/oxc_linter/src/lib.rs @@ -347,14 +347,18 @@ impl Linter { let mut timing_recorder = TIMINGS.then(|| RuleTimingRecorder::with_capacity(rules.len())); // The shared React Compiler run takes its environment additions from the - // `react/capitalized-calls` rule's resolved options. - let react_compiler_env_options = rules + // `react/capitalized-calls` rule's resolved options, and runs the passes + // that only report `Todo` and `Invariant` when their rules are enabled. + let mut react_compiler_env_options = rules .iter() .find_map(|(rule, _)| match rule { RuleEnum::ReactCapitalizedCalls(rule) => Some(rule.react_compiler_env_options()), _ => None, }) .unwrap_or_default(); + react_compiler_env_options.report_todo_and_invariant = rules + .iter() + .any(|(rule, _)| matches!(rule, RuleEnum::ReactTodo(_) | RuleEnum::ReactInvariant(_))); let mut ctx_host = Rc::new( ContextHost::new(path, context_sub_hosts, allocator, self.options, config) diff --git a/crates/oxc_linter/src/rules/react/capitalized_calls.rs b/crates/oxc_linter/src/rules/react/capitalized_calls.rs index b05e0648c1..257a934aaf 100644 --- a/crates/oxc_linter/src/rules/react/capitalized_calls.rs +++ b/crates/oxc_linter/src/rules/react/capitalized_calls.rs @@ -92,6 +92,7 @@ impl CapitalizedCalls { ReactCompilerEnvOptions { capitalized_calls_allow: self.0.allow.clone(), capitalized_calls_allow_pattern: self.0.allow_pattern.clone(), + ..ReactCompilerEnvOptions::default() } } } diff --git a/crates/oxc_linter/src/utils/react_compiler.rs b/crates/oxc_linter/src/utils/react_compiler.rs index 3abd41da0e..3868fa20bc 100644 --- a/crates/oxc_linter/src/utils/react_compiler.rs +++ b/crates/oxc_linter/src/utils/react_compiler.rs @@ -14,15 +14,18 @@ use crate::{ /// 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. +/// `react/capitalized-calls` rule's options and whether `react/todo` or +/// `react/invariant` is enabled). 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, /// Allowed-name pattern for `validateNoCapitalizedCalls`. pub capitalized_calls_allow_pattern: Option, + /// Whether `react/todo` or `react/invariant` is enabled. Otherwise lint skips + /// the compiler passes that only report those categories. + pub report_todo_and_invariant: bool, } /// The compiler options the React Compiler family of rules lints with — `lint` @@ -68,6 +71,7 @@ fn react_compiler_plugin_options(env_options: &ReactCompilerEnvOptions) -> Plugi validate_exhaustive_effect_dependencies: ExhaustiveEffectDepsMode::All, ..EnvironmentConfig::default() }, + lint_skip_reactive_scope_passes: !env_options.report_todo_and_invariant, ..PluginOptions::default() } } diff --git a/crates/oxc_react_compiler/fixtures/exhaustive-deps/lint-effect-deps-without-manual-memo.js b/crates/oxc_react_compiler/fixtures/exhaustive-deps/lint-effect-deps-without-manual-memo.js new file mode 100644 index 0000000000..300500232f --- /dev/null +++ b/crates/oxc_react_compiler/fixtures/exhaustive-deps/lint-effect-deps-without-manual-memo.js @@ -0,0 +1,15 @@ +// @validateExhaustiveEffectDependencies:"all" @outputMode:"lint" @panicThreshold:"none" @lintSkipReactiveScopePasses +import {useEffect, useRef, useState} from 'react'; + +// Without useMemo/useCallback, lint skips the reactive-scope passes but still +// infers reactive places for the effect: props are required dependencies, +// while the state setter and the ref are stable and may be omitted. +function Component({value, other}) { + const [count, setCount] = useState(0); + const ref = useRef(null); + useEffect(() => { + setCount(value); + ref.current = other; + }, []); + return
{count}
; +} diff --git a/crates/oxc_react_compiler/fixtures/exhaustive-deps/lint-effect-hook-destructured-from-react.js b/crates/oxc_react_compiler/fixtures/exhaustive-deps/lint-effect-hook-destructured-from-react.js new file mode 100644 index 0000000000..e30821f6e5 --- /dev/null +++ b/crates/oxc_react_compiler/fixtures/exhaustive-deps/lint-effect-hook-destructured-from-react.js @@ -0,0 +1,12 @@ +// @validateExhaustiveEffectDependencies:"all" @outputMode:"lint" @panicThreshold:"none" @lintSkipReactiveScopePasses +import * as React from 'react'; + +// The effect hook is only recognisable by its type: the callee is a local +// destructured from the React namespace, not a `useEffect` load or property. +function Component({value}) { + const {useEffect} = React; + useEffect(() => { + console.log(value); + }, []); + return
{value}
; +} diff --git a/crates/oxc_react_compiler/fixtures/lint-reports-todo-from-reactive-scope-passes.js b/crates/oxc_react_compiler/fixtures/lint-reports-todo-from-reactive-scope-passes.js new file mode 100644 index 0000000000..1c9db6617e --- /dev/null +++ b/crates/oxc_react_compiler/fixtures/lint-reports-todo-from-reactive-scope-passes.js @@ -0,0 +1,14 @@ +// @outputMode:"lint" @panicThreshold:"none" +import {Stringify} from 'shared-runtime'; + +// Lint reports the Todo that a pass after validation records for the hoisted +// function reference. +function Foo() { + function foo() { + return bar(); + } + function bar() { + return 42; + } + return ; +} diff --git a/crates/oxc_react_compiler/src/options.rs b/crates/oxc_react_compiler/src/options.rs index 1034e87b2e..7d1cdbec2d 100644 --- a/crates/oxc_react_compiler/src/options.rs +++ b/crates/oxc_react_compiler/src/options.rs @@ -154,6 +154,11 @@ pub struct PluginOptions { /// on top of the built-in `"use no memo"` / `"use no forget"`. pub custom_opt_out_directives: Option>, + /// In lint output mode, skip the reactive-scope passes for functions without + /// `useMemo`/`useCallback`. Those passes only report `Todo` and `Invariant` + /// diagnostics, so set this only when those diagnostics are not needed. + pub lint_skip_reactive_scope_passes: bool, + /// Compiler environment: feature flags, validation toggles, and custom hook/type /// definitions for the compilation passes. See [`EnvironmentConfig`]. pub environment: EnvironmentConfig, @@ -175,6 +180,7 @@ impl Default for PluginOptions { flow_suppressions: true, ignore_use_no_forget: false, custom_opt_out_directives: None, + lint_skip_reactive_scope_passes: false, environment: EnvironmentConfig::default(), } } diff --git a/crates/oxc_react_compiler/src/react_compiler/entrypoint/pipeline.rs b/crates/oxc_react_compiler/src/react_compiler/entrypoint/pipeline.rs index 2344167db0..5734467d9b 100644 --- a/crates/oxc_react_compiler/src/react_compiler/entrypoint/pipeline.rs +++ b/crates/oxc_react_compiler/src/react_compiler/entrypoint/pipeline.rs @@ -16,8 +16,9 @@ use crate::react_compiler_hir::environment::Environment; use crate::react_compiler_hir::environment::OutputMode; use crate::react_compiler_hir::environment_config::{EnvironmentConfig, ExhaustiveEffectDepsMode}; use crate::react_compiler_hir::{ - ReactFunctionType, assert_consistent_identifiers, assert_terminal_preds_exist, - assert_terminal_successors_exist, assert_valid_block_nesting, + HirFunction, InstructionValue, ReactFunctionType, assert_consistent_identifiers, + assert_terminal_preds_exist, assert_terminal_successors_exist, assert_valid_block_nesting, + is_use_effect_hook_type, is_use_insertion_effect_hook_type, is_use_layout_effect_hook_type, }; use crate::react_compiler_inference::align_method_call_scopes; use crate::react_compiler_inference::align_object_method_scopes; @@ -231,6 +232,31 @@ pub fn compile_fn<'a, const EMIT: bool>( validate_no_freezing_known_mutable_functions(&hir, &mut env); } + // Lint does not emit a rewrite. Without useMemo/useCallback there is no + // manual memoization to validate, so callers that do not need `Todo` and + // `Invariant` diagnostics can skip the reactive-scope passes below, which + // only report those. Reactive places are still inferred when an effect hook + // call needs its dependencies checked. + if !EMIT + && env.output_mode == OutputMode::Lint + && context.opts.lint_skip_reactive_scope_passes + && !hir_has_start_memoize(&hir) + { + if env.enable_validations() { + if env.config.validate_static_components { + let errors = validate_static_components(&hir, &env.functions); + log_errors_as_events(&errors, context); + } + if env.config.validate_exhaustive_effect_dependencies != ExhaustiveEffectDepsMode::Off + && calls_effect_hook(&hir, &env) + { + infer_reactive_places(&mut hir, &mut env)?; + validate_exhaustive_dependencies(&mut hir, &mut env)?; + } + } + return finish_lint_fn(&mut env, context); + } + infer_reactive_places(&mut hir, &mut env)?; if env.enable_validations() @@ -384,3 +410,45 @@ pub fn compile_fn<'a, const EMIT: bool>( fn log_errors_as_events(errors: &Diagnostics, context: &mut ProgramContext) { context.diagnostics.extend(errors.iter().cloned()); } + +fn hir_has_start_memoize(hir: &HirFunction<'_>) -> bool { + hir.instructions + .iter() + .any(|instruction| matches!(instruction.value, InstructionValue::StartMemoize { .. })) +} + +/// Whether `hir` calls an effect hook, by type — the calls +/// [`validate_exhaustive_dependencies`] checks. Nested functions are not +/// scanned because the validator does not check effect calls inside them. +fn calls_effect_hook(hir: &HirFunction<'_>, env: &Environment<'_>) -> bool { + hir.instructions.iter().any(|instruction| { + let callee = match &instruction.value { + InstructionValue::CallExpression { callee, .. } => callee, + InstructionValue::MethodCall { property, .. } => property, + _ => return false, + }; + let ty = &env.types[env.identifiers[callee.identifier].type_]; + is_use_effect_hook_type(ty) + || is_use_layout_effect_hook_type(ty) + || is_use_insertion_effect_hook_type(ty) + }) +} + +fn finish_lint_fn<'a>( + env: &mut Environment<'a>, + context: &mut ProgramContext<'a>, +) -> Result>, Diagnostics> { + if env.config.throw_unknown_exception_testonly { + return Err(Diagnostics::from(diagnostics::invariant_unexpected_error())); + } + if env.has_errors() { + if let Some(uid_names) = env.take_uid_known_names() { + context.merge_uid_known_names(&uid_names); + } + return Err(env.take_errors()); + } + if let Some(uid_names) = env.take_uid_known_names() { + context.merge_uid_known_names(&uid_names); + } + Ok(None) +} diff --git a/crates/oxc_react_compiler/src/react_compiler/entrypoint/program.rs b/crates/oxc_react_compiler/src/react_compiler/entrypoint/program.rs index a78b196bfd..8e200ce171 100644 --- a/crates/oxc_react_compiler/src/react_compiler/entrypoint/program.rs +++ b/crates/oxc_react_compiler/src/react_compiler/entrypoint/program.rs @@ -2065,24 +2065,22 @@ fn find_functions_to_compile<'b, 'ast>( // ----------------------------------------------------------------------- /// Cheap, sound pre-check for [`find_functions_to_compile`]: `false` means the -/// discovery walk cannot queue anything, so the compile is a no-op. Built from -/// data `Semantic` already computed instead of walking the AST, and delegating -/// all judgment to discovery's own helpers: +/// discovery walk cannot queue anything, so the compile is a no-op. /// -/// - every discoverable function creates a function scope, so the node behind -/// each function scope is run through [`try_make_compile_source`] with the -/// name discovery would infer — own id for declarations, the directly -/// enclosing `const Foo = ...` declarator for expressions/arrows, the only -/// name sources discovery uses outside forwardRef/memo. This covers the -/// named and directive-opt-in selection paths, nested functions included; -/// - the forwardRef/memo path needs an identifier named `memo`, `forwardRef`, -/// or `React` in callee position of a call [`get_callee_name_if_react_api`] -/// recognizes, so checking the reference shapes of those three names — -/// bindings and unresolved globals — covers wrapped anonymous functions. +/// `precise` (compile/emit) runs each function through [`try_make_compile_source`] +/// so files like `react.development.js` — hook *implementations*, not callers — +/// do not pay a discovery walk that queues nothing. Lint uses a name-only +/// over-approximation: a PascalCase / `useX` name or a non-empty directive list +/// is enough; the JSX/hook body scan is left to discovery. +/// +/// The forwardRef/memo path needs an identifier named `memo`, `forwardRef`, +/// or `React` in callee position of a call [`get_callee_name_if_react_api`] +/// recognizes, so checking the reference shapes of those three names — +/// bindings and unresolved globals — covers wrapped anonymous functions. /// /// Over-approximation is fine (the walk then finds an empty queue); a missed /// witness is not, since a skipped file is never compiled. -fn may_have_functions_to_compile(semantic: &Semantic, opts: &PluginOptions) -> bool { +fn may_have_functions_to_compile(semantic: &Semantic, opts: &PluginOptions, precise: bool) -> bool { // 'all' mode compiles every top-level function; always walk. if opts.compilation_mode == CompilationMode::All { return true; @@ -2104,9 +2102,7 @@ fn may_have_functions_to_compile(semantic: &Semantic, opts: &PluginOptions) -> b } } - // Named components/hooks and directive opt-ins: run the node behind every - // function scope through discovery's candidate constructor. parent_callee - // is None — wrapper-selected functions are witnessed by reference shapes. + // Named components/hooks and directive opt-ins. let mut discarded = FxHashSet::default(); for scope_id in scoping.scope_descendants_from_root() { if !scoping.scope_flags(scope_id).is_function() { @@ -2124,7 +2120,6 @@ fn may_have_functions_to_compile(semantic: &Semantic, opts: &PluginOptions) -> b (declarator_name_for(nodes, node.id()), OriginalFnKind::FunctionExpression) } }; - // A nameless function without directives can never classify. if name.is_none() && func.body.as_ref().is_none_or(|body| body.directives.is_empty()) { @@ -2143,8 +2138,21 @@ fn may_have_functions_to_compile(semantic: &Semantic, opts: &PluginOptions) -> b } _ => continue, }; - if try_make_compile_source(fn_node, name, original_kind, None, opts, &mut discarded) - .is_some() + if precise { + if try_make_compile_source(fn_node, name, original_kind, None, opts, &mut discarded) + .is_some() + { + return true; + } + } else if name.is_some_and(|n| is_hook_name(n) || is_component_name(n)) + || match fn_node { + FunctionNode::Function(func) => { + func.body.as_ref().is_some_and(|body| !body.directives.is_empty()) + } + FunctionNode::Arrow(arrow) => { + arrow.get_function_body().is_some_and(|body| !body.directives.is_empty()) + } + } { return true; } @@ -3143,7 +3151,7 @@ pub fn compile_program<'a, const EMIT: bool>( // Find all functions to compile. An empty queue means no work, so return // before all the setup below, which only compilation needs. The pre-check // decides emptiness from semantic data without walking the AST. - if !may_have_functions_to_compile(semantic, &options) { + if !may_have_functions_to_compile(semantic, &options, EMIT) { return CompileResult::Success { output: None, diagnostics: Diagnostics::new() }; } let queue = find_functions_to_compile(program, &options); @@ -3193,10 +3201,10 @@ pub fn compile_program<'a, const EMIT: bool>( let ast = AstBuilder::new(allocator); let scope = ScopeResolver::new(semantic, allocator); - // Initialize known referenced names from scope bindings for UID collision detection - context.init_from_scope(&scope); - if EMIT { + // Seed UID collision detection with every binding in the file. Lint + // does not generate identifiers, so skip the walk. + context.init_from_scope(&scope); // Pre-register instrumentation imports to get stable local names. // These are needed before compilation so codegen can use the correct names. let (instrument_fn_name, instrument_gating_name) = if let Some(ref instrument_config) = diff --git a/crates/oxc_react_compiler/tests/snapshot.rs b/crates/oxc_react_compiler/tests/snapshot.rs index 773aeef153..6b05f703f2 100644 --- a/crates/oxc_react_compiler/tests/snapshot.rs +++ b/crates/oxc_react_compiler/tests/snapshot.rs @@ -221,6 +221,7 @@ fn parse_pragma(source: &str) -> (SourceType, PluginOptions) { } } "outputMode" => options.output_mode = value.and_then(|v| unquote(v).parse().ok()), + "lintSkipReactiveScopePasses" => options.lint_skip_reactive_scope_passes = true, // Fixed test default (upstream `testComplexPluginOptionDefaults`). "gating" => { options.gating = Some(GatingConfig { diff --git a/crates/oxc_react_compiler/tests/snapshots/exhaustive-deps__lint-effect-deps-without-manual-memo.snap b/crates/oxc_react_compiler/tests/snapshots/exhaustive-deps__lint-effect-deps-without-manual-memo.snap new file mode 100644 index 0000000000..9d061ba28d --- /dev/null +++ b/crates/oxc_react_compiler/tests/snapshots/exhaustive-deps__lint-effect-deps-without-manual-memo.snap @@ -0,0 +1,22 @@ +--- +source: crates/oxc_react_compiler/tests/snapshot.rs +input_file: crates/oxc_react_compiler/fixtures/exhaustive-deps/lint-effect-deps-without-manual-memo.js +--- +Diagnostics: + +OxcDiagnostic { inner: OxcDiagnosticInner { message: "Found missing effect dependencies", labels: [LabeledSpan { label: Some("This dependency list is missing values used by the callback"), span: Span { start: 570, end: 572 }, primary: true }, LabeledSpan { label: Some("Missing dependency `other`"), span: Span { start: 558, end: 563 }, primary: false }, LabeledSpan { label: Some("Missing dependency `value`"), span: Span { start: 532, end: 537 }, primary: false }], help: Some("Missing dependencies can cause an effect to fire less often than it should"), note: Some("React Compiler skipped optimizing this component or hook"), severity: Error, code: OxcCode { scope: Some("react-compiler"), number: Some("EffectExhaustiveDependencies") }, url: Some("https://react.dev/reference/eslint-plugin-react-hooks/lints/exhaustive-deps") } } + +// @validateExhaustiveEffectDependencies:"all" @outputMode:"lint" @panicThreshold:"none" @lintSkipReactiveScopePasses +import { useEffect, useRef, useState } from "react"; +// Without useMemo/useCallback, lint skips the reactive-scope passes but still +// infers reactive places for the effect: props are required dependencies, +// while the state setter and the ref are stable and may be omitted. +function Component({ value, other }) { + const [count, setCount] = useState(0); + const ref = useRef(null); + useEffect(() => { + setCount(value); + ref.current = other; + }, []); + return
{count}
; +} diff --git a/crates/oxc_react_compiler/tests/snapshots/exhaustive-deps__lint-effect-hook-destructured-from-react.snap b/crates/oxc_react_compiler/tests/snapshots/exhaustive-deps__lint-effect-hook-destructured-from-react.snap new file mode 100644 index 0000000000..a86ade5d6b --- /dev/null +++ b/crates/oxc_react_compiler/tests/snapshots/exhaustive-deps__lint-effect-hook-destructured-from-react.snap @@ -0,0 +1,20 @@ +--- +source: crates/oxc_react_compiler/tests/snapshot.rs +input_file: crates/oxc_react_compiler/fixtures/exhaustive-deps/lint-effect-hook-destructured-from-react.js +--- +Diagnostics: + +OxcDiagnostic { inner: OxcDiagnosticInner { message: "Hooks may not be referenced as normal values; they must be called", labels: [LabeledSpan { label: Some("This Hook is used as a value"), span: Span { start: 365, end: 374 }, primary: true }], help: Some("Call the Hook directly instead of passing or storing it as a value"), note: Some("React Compiler skipped optimizing this component or hook"), severity: Error, code: OxcCode { scope: Some("react-compiler"), number: Some("Hooks") }, url: Some("https://react.dev/reference/eslint-plugin-react-hooks/lints/rules-of-hooks") } } +OxcDiagnostic { inner: OxcDiagnosticInner { message: "Found missing effect dependencies", labels: [LabeledSpan { label: Some("This dependency list is missing values used by the callback"), span: Span { start: 412, end: 414 }, primary: true }, LabeledSpan { label: Some("Missing dependency `value`"), span: Span { start: 399, end: 404 }, primary: false }], help: Some("Missing dependencies can cause an effect to fire less often than it should"), note: Some("React Compiler skipped optimizing this component or hook"), severity: Error, code: OxcCode { scope: Some("react-compiler"), number: Some("EffectExhaustiveDependencies") }, url: Some("https://react.dev/reference/eslint-plugin-react-hooks/lints/exhaustive-deps") } } + +// @validateExhaustiveEffectDependencies:"all" @outputMode:"lint" @panicThreshold:"none" @lintSkipReactiveScopePasses +import * as React from "react"; +// The effect hook is only recognisable by its type: the callee is a local +// destructured from the React namespace, not a `useEffect` load or property. +function Component({ value }) { + const { useEffect } = React; + useEffect(() => { + console.log(value); + }, []); + return
{value}
; +} diff --git a/crates/oxc_react_compiler/tests/snapshots/lint-reports-todo-from-reactive-scope-passes.snap b/crates/oxc_react_compiler/tests/snapshots/lint-reports-todo-from-reactive-scope-passes.snap new file mode 100644 index 0000000000..2681e4123d --- /dev/null +++ b/crates/oxc_react_compiler/tests/snapshots/lint-reports-todo-from-reactive-scope-passes.snap @@ -0,0 +1,21 @@ +--- +source: crates/oxc_react_compiler/tests/snapshot.rs +input_file: crates/oxc_react_compiler/fixtures/lint-reports-todo-from-reactive-scope-passes.js +--- +Diagnostics: + +OxcDiagnostic { inner: OxcDiagnosticInner { message: "[PruneHoistedContexts] Rewrite hoisted function references", labels: [LabeledSpan { label: None, span: Span { start: 236, end: 239 }, primary: true }], help: Some("Rewrite the highlighted code using syntax supported by React Compiler"), note: Some("React Compiler skipped optimizing this component or hook"), severity: Advice, code: OxcCode { scope: Some("react-compiler"), number: Some("Todo") }, url: Some("https://react.dev/reference/eslint-plugin-react-hooks/lints/unsupported-syntax") } } + +// @outputMode:"lint" @panicThreshold:"none" +import { Stringify } from "shared-runtime"; +// Lint reports the Todo that a pass after validation records for the hoisted +// function reference. +function Foo() { + function foo() { + return bar(); + } + function bar() { + return 42; + } + return ; +}