diff --git a/tools/analyzer_plugin/README.md b/tools/analyzer_plugin/README.md index 214a927fb..0a73e5848 100644 --- a/tools/analyzer_plugin/README.md +++ b/tools/analyzer_plugin/README.md @@ -107,6 +107,21 @@ This script sets up a symlink to point to the original plugin directory (replaci * If you need to get the source for a replacement, use `sourceFile.getText(node.offset, node.end)`. ### Debugging the Plugin + +#### Showing Debug Info + +Via `AnalyzerDebugHelper`, it's possible for diagnostics to show debug information for specific locations in a file +as infos, which can be helpful to develop/troubleshoot +issues without having to attach a debugger. + +Some parts of the plugin show this debug info based on the presence of a comment anywhere in the file. +Currently-available debug info: +- `// debug: over_react_boilerplate`- shows how over_react boilerplate will be parsed/detected by the over_react + builder and analyzer functionality dealing with component declarations +- `// debug: over_react_metrics` - shows performance data on how long diagnostics took to run +- `// debug: over_react_exhaustive_deps` - shows info on how dependencies were detected/interpreted + +#### Attaching a Debugger The dev experience when working on this plugin isn't ideal (See the `analyzer_plugin` debugging docs [for more information](https://github.com/dart-lang/sdk/blob/master/pkg/analyzer_plugin/doc/tutorial/debugging.md)), but it's possible debug and see logs from the plugin. These instructions are currently for JetBrains IDEs (IntelliJ, WebStorm, etc.) only. diff --git a/tools/analyzer_plugin/analysis_options.yaml b/tools/analyzer_plugin/analysis_options.yaml index e165bb0c6..c1040a326 100644 --- a/tools/analyzer_plugin/analysis_options.yaml +++ b/tools/analyzer_plugin/analysis_options.yaml @@ -21,6 +21,8 @@ analyzer: prefer_single_quotes: ignore type_annotate_public_apis: ignore prefer_final_locals: ignore + # This is less readable in some cases, and isn't worth it. + join_return_with_assignment: ignore linter: rules: diff --git a/tools/analyzer_plugin/lib/src/analysis_options/parse.dart b/tools/analyzer_plugin/lib/src/analysis_options/parse.dart index 74e5c9ff5..be1004133 100644 --- a/tools/analyzer_plugin/lib/src/analysis_options/parse.dart +++ b/tools/analyzer_plugin/lib/src/analysis_options/parse.dart @@ -3,6 +3,7 @@ import 'package:yaml/yaml.dart'; /// Parses the given yaml and returns over react analyzer plugin configuration options. PluginAnalysisOptions? processAnalysisOptionsFile(String fileContents) { + // TODO catch parse errors, add test coverage final yaml = loadYamlNode(fileContents); if (yaml is YamlMap) { return _parseAnalysisOptions(yaml); @@ -15,14 +16,31 @@ PluginAnalysisOptions? processAnalysisOptionsFile(String fileContents) { PluginAnalysisOptions? _parseAnalysisOptions(YamlMap yaml) { final dynamic overReact = yaml['over_react']; if (overReact is YamlMap) { - final errors = _parseErrors(overReact); - return PluginAnalysisOptions(errors: errors); + return PluginAnalysisOptions( + errors: _parseErrors(overReact), + exhaustiveDepsAdditionalHooksPattern: _parseExhaustiveDepsAdditionalHooksPattern(overReact), + ); } // If there is no `over_react` key in the yaml file, return null. return null; } +RegExp? _parseExhaustiveDepsAdditionalHooksPattern(YamlMap overReact) { + final dynamic exhaustiveDeps = overReact['exhaustive_deps']; + if (exhaustiveDeps is! YamlMap) return null; + + final dynamic additionalHooks = exhaustiveDeps['additional_hooks']; + if (additionalHooks is! String) return null; + + try { + // This will throw if the regex is invalid. + return RegExp(additionalHooks); + } catch (_) { + return null; + } +} + Map _parseErrors(YamlMap overReact) { final dynamic errors = overReact['errors']; if (errors is YamlMap) { diff --git a/tools/analyzer_plugin/lib/src/analysis_options/plugin_analysis_options.dart b/tools/analyzer_plugin/lib/src/analysis_options/plugin_analysis_options.dart index 5d2df3958..b3f5d7d11 100644 --- a/tools/analyzer_plugin/lib/src/analysis_options/plugin_analysis_options.dart +++ b/tools/analyzer_plugin/lib/src/analysis_options/plugin_analysis_options.dart @@ -1,7 +1,12 @@ /// An object containing the data under the `over_react` key in a analysis_options.yaml file. class PluginAnalysisOptions { final Map errors; - PluginAnalysisOptions({required this.errors}); + final RegExp? exhaustiveDepsAdditionalHooksPattern; + + PluginAnalysisOptions({ + required this.errors, + this.exhaustiveDepsAdditionalHooksPattern, + }); } enum AnalysisOptionsSeverity { diff --git a/tools/analyzer_plugin/lib/src/analysis_options/reader.dart b/tools/analyzer_plugin/lib/src/analysis_options/reader.dart index 2dbc65ce4..84050c5f8 100644 --- a/tools/analyzer_plugin/lib/src/analysis_options/reader.dart +++ b/tools/analyzer_plugin/lib/src/analysis_options/reader.dart @@ -1,32 +1,31 @@ +import 'package:analyzer/dart/analysis/context_root.dart'; import 'package:analyzer/dart/analysis/results.dart'; -import 'package:analyzer/file_system/file_system.dart'; -import 'package:over_react_analyzer_plugin/src/analysis_options/plugin_analysis_options.dart'; +import 'package:analyzer/file_system/file_system.dart' as analyzer_fs; import 'package:over_react_analyzer_plugin/src/analysis_options/parse.dart'; +import 'package:over_react_analyzer_plugin/src/analysis_options/plugin_analysis_options.dart'; -/// An analysis_options.yaml reader that parses the appropriate analysis_options.yaml file for the given -/// [ResolvedUnitResult] and returns the configuration options for the over react analyzer plugin. +/// An analysis_options.yaml reader that parses the appropriate analysis_options.yaml file +/// and returns the configuration options for the over react analyzer plugin. /// /// The reader uses caching to reduce the number of file reads. If a result is given that uses the same /// analysis_options.yaml as a previous result, the reader will return a cache version. -class AnalysisOptionsReader { - final _cachedAnalysisOptions = {}; +class PluginOptionsReader { + final _cachedOptions = {}; - PluginAnalysisOptions? getAnalysisOptionsForResult(ResolvedUnitResult result) { - final file = result.session.analysisContext.contextRoot.optionsFile; - if (file != null) { - return _getAnalysisOptionForFilePath(file); - } + PluginAnalysisOptions? getOptionsForContextRoot(ContextRoot root) { + final file = root.optionsFile; + if (file == null) return null; - // There is no analysis_options.yaml, so return null. - return null; + return _getOptionsFromOptionsFile(file); } - PluginAnalysisOptions? _getAnalysisOptionForFilePath(File file) { - return _cachedAnalysisOptions.putIfAbsent(file.path, () => _readAnalysisOptionForFilePath(file)); - } + PluginAnalysisOptions? getOptionsForResult(ResolvedUnitResult result) => + getOptionsForContextRoot(result.session.analysisContext.contextRoot); - PluginAnalysisOptions? _readAnalysisOptionForFilePath(File file) { - final fileContents = file.readAsStringSync(); - return processAnalysisOptionsFile(fileContents); + PluginAnalysisOptions? _getOptionsFromOptionsFile(analyzer_fs.File file) { + return _cachedOptions.putIfAbsent(file.path, () { + if (!file.exists) return null; + return processAnalysisOptionsFile(file.readAsStringSync()); + }); } } diff --git a/tools/analyzer_plugin/lib/src/assist/refs/add_create_ref.dart b/tools/analyzer_plugin/lib/src/assist/refs/add_create_ref.dart index d753da4f8..2269e790e 100644 --- a/tools/analyzer_plugin/lib/src/assist/refs/add_create_ref.dart +++ b/tools/analyzer_plugin/lib/src/assist/refs/add_create_ref.dart @@ -87,8 +87,9 @@ void addUseOrCreateRef( } if (refTypeToReplace == RefTypeToReplace.callback) { + if (createRefField == null) return; // Its a callback ref - meaning there is an existing field we need to update. - final declOfVarRefIsAssignedTo = lookUpVariable(createRefField, result.unit); + final declOfVarRefIsAssignedTo = lookUpVariable(createRefField, result.unit!); if (declOfVarRefIsAssignedTo == null) return; final nodeToReplace = diff --git a/tools/analyzer_plugin/lib/src/async_plugin_apis/diagnostic.dart b/tools/analyzer_plugin/lib/src/async_plugin_apis/diagnostic.dart index a30bc495b..90123d567 100644 --- a/tools/analyzer_plugin/lib/src/async_plugin_apis/diagnostic.dart +++ b/tools/analyzer_plugin/lib/src/async_plugin_apis/diagnostic.dart @@ -39,6 +39,7 @@ import 'package:analyzer_plugin/protocol/protocol.dart'; import 'package:analyzer_plugin/protocol/protocol_common.dart'; import 'package:analyzer_plugin/protocol/protocol_generated.dart' as plugin; import 'package:analyzer_plugin/protocol/protocol_generated.dart'; + // ignore: implementation_imports import 'package:analyzer_plugin/src/utilities/fixes/fixes.dart'; import 'package:analyzer_plugin/utilities/fixes/fixes.dart'; @@ -46,11 +47,13 @@ import 'package:meta/meta.dart'; import 'package:over_react_analyzer_plugin/src/async_plugin_apis/error_severity_provider.dart'; import 'package:over_react_analyzer_plugin/src/analysis_options/error_severity_provider.dart'; import 'package:over_react_analyzer_plugin/src/analysis_options/reader.dart'; +import 'package:over_react_analyzer_plugin/src/diagnostic/analyzer_debug_helper.dart'; import 'package:over_react_analyzer_plugin/src/diagnostic_contributor.dart'; import 'package:over_react_analyzer_plugin/src/error_filtering.dart'; +import 'package:over_react_analyzer_plugin/src/util/pretty_print.dart'; mixin DiagnosticMixin on ServerPlugin { - final AnalysisOptionsReader _analysisOptionsReader = AnalysisOptionsReader(); + PluginOptionsReader get pluginOptionsReader; List getDiagnosticContributors(String path); @@ -62,7 +65,7 @@ mixin DiagnosticMixin on ServerPlugin { /// Computes errors based on an analysis result, notifies the analyzer, and /// then returns the list of errors. Future> getAllErrors(ResolvedUnitResult analysisResult) async { - final analysisOptions = _analysisOptionsReader.getAnalysisOptionsForResult(analysisResult); + final analysisOptions = pluginOptionsReader.getOptionsForResult(analysisResult); try { // If there is no relevant analysis result, notify the analyzer of no errors. @@ -93,7 +96,7 @@ mixin DiagnosticMixin on ServerPlugin { Future handleEditGetFixes(plugin.EditGetFixesParams parameters) async { // We want request errors to propagate if they throw final request = await _getFixesRequest(parameters); - final analysisOptions = _analysisOptionsReader.getAnalysisOptionsForResult(request.result); + final analysisOptions = pluginOptionsReader.getOptionsForResult(request.result); try { final generator = _DiagnosticGenerator( @@ -118,11 +121,11 @@ mixin DiagnosticMixin on ServerPlugin { return DartFixesRequestImpl(resourceProvider, offset, [], result); } - // from DartFixesMixin +// from DartFixesMixin // List _getErrors(int offset, ResolvedUnitResult result) { // LineInfo lineInfo = result.lineInfo; // int offsetLine = lineInfo.getLocation(offset).lineNumber; - // these errors don't include ones from the plugin, which doesn't seem right... +// these errors don't include ones from the plugin, which doesn't seem right... // return result.errors.where((AnalysisError error) { // int errorLine = lineInfo.getLocation(error.offset).lineNumber; // return errorLine == offsetLine; @@ -135,6 +138,8 @@ mixin DiagnosticMixin on ServerPlugin { /// a given result unit or fixes request. @sealed class _DiagnosticGenerator { + static final _metricsDebugCommentPattern = getDebugCommentPattern('over_react_metrics'); + /// Initialize a newly created errors generator to use the given /// [contributors]. _DiagnosticGenerator(this.contributors, {required ErrorSeverityProvider errorSeverityProvider}) @@ -203,15 +208,26 @@ class _DiagnosticGenerator { // TODO: collect data how long this takes. List getUsages() => _usages ??= getAllComponentUsages(unitResult.unit!); + /// A mapping of diagnostic names to their durations, in microseconds. + final diagnosticMetrics = {}; + + final metricsDebugFlagMatch = _metricsDebugCommentPattern.firstMatch(unitResult.content ?? ''); + + final totalStopwatch = Stopwatch()..start(); + final disabledCheckStopwatch = Stopwatch()..start(); + for (final contributor in contributors) { + disabledCheckStopwatch.start(); final isEveryCodeDisabled = contributor.codes.every( (e) => _errorSeverityProvider.isCodeDisabled(e.name), ); + disabledCheckStopwatch.stop(); if (isEveryCodeDisabled) { // Don't compute errors if all of the codes provided by the contributor are disabled continue; } + final contributorStopwatch = Stopwatch()..start(); try { if (contributor is ComponentUsageDiagnosticContributor) { await contributor.computeErrorsForUsages(unitResult, collector, getUsages()); @@ -221,6 +237,31 @@ class _DiagnosticGenerator { } catch (exception, stackTrace) { notifications.add(PluginErrorParams(false, exception.toString(), stackTrace.toString()).toNotification()); } + contributorStopwatch.stop(); + if (metricsDebugFlagMatch != null) { + diagnosticMetrics[contributor.runtimeType.toString()] = contributorStopwatch.elapsedMicroseconds; + } + } + + totalStopwatch.stop(); + if (metricsDebugFlagMatch != null) { + String formatMicroseconds(int microseconds) => + '${(microseconds / Duration.microsecondsPerMillisecond).toStringAsFixed(3)}ms'; + String asPercentageOfTotal(int microseconds) => + '${(microseconds / totalStopwatch.elapsedMicroseconds * 100).toStringAsFixed(1)}%'; + + final message = 'OverReact Analyzer Plugin diagnostic metrics (current file): ' + + prettyPrint({ + ...diagnosticMetrics.map((name, microseconds) => + MapEntry(name, '${formatMicroseconds(microseconds)} (${asPercentageOfTotal(microseconds)})')), + 'Total': formatMicroseconds(totalStopwatch.elapsedMicroseconds), + 'Diagnostic code disabled checks': formatMicroseconds(disabledCheckStopwatch.elapsedMicroseconds), + 'Loop overhead (Total - SUM(Diagnostics))': formatMicroseconds(totalStopwatch.elapsedMicroseconds - + disabledCheckStopwatch.elapsedMicroseconds - + diagnosticMetrics.values.fold(0, (a, b) => a + b)), + }); + AnalyzerDebugHelper(unitResult, collector, enabled: true).logWithLocation( + message, unitResult.location(offset: metricsDebugFlagMatch.start, end: metricsDebugFlagMatch.end)); } final filteredErrors = _configureErrorSeverities( diff --git a/tools/analyzer_plugin/lib/src/diagnostic/analyzer_debug_helper.dart b/tools/analyzer_plugin/lib/src/diagnostic/analyzer_debug_helper.dart index bfd5aecd6..2a10e0858 100644 --- a/tools/analyzer_plugin/lib/src/diagnostic/analyzer_debug_helper.dart +++ b/tools/analyzer_plugin/lib/src/diagnostic/analyzer_debug_helper.dart @@ -35,3 +35,14 @@ class AnalyzerDebugHelper { collector.addError(code, location, errorMessageArgs: [message]); } } + +/// Returns a pattern that matches a file with a `// debug:` comment with the given [debugCode]. +/// +/// Useful for conditionally enabling [AnalyzerDebugHelper] infos based on the presence of a comment +/// in a file. +/// +/// For example, `getDebugCommentPattern('foo')` will match files containing the following comments: +/// +/// - `// debug: foo` +/// - `//debug:foo,bar` +RegExp getDebugCommentPattern(String debugCode) => RegExp(r'//\s*debug:.*\b' + RegExp.escape(debugCode) + r'\b'); diff --git a/tools/analyzer_plugin/lib/src/diagnostic/boilerplate_validator.dart b/tools/analyzer_plugin/lib/src/diagnostic/boilerplate_validator.dart index f2a02f06e..9fa0c5e41 100644 --- a/tools/analyzer_plugin/lib/src/diagnostic/boilerplate_validator.dart +++ b/tools/analyzer_plugin/lib/src/diagnostic/boilerplate_validator.dart @@ -12,6 +12,8 @@ import 'package:over_react_analyzer_plugin/src/over_react_builder_parsing.dart' import 'package:over_react_analyzer_plugin/src/util/boilerplate_utils.dart'; import 'package:source_span/source_span.dart'; +import 'analyzer_debug_helper.dart'; + const _errorDesc = 'Something is malformed in your component boilerplate.'; // TODO: Add a more detailed description about the types of things our validator catches // @@ -59,7 +61,7 @@ class BoilerplateValidatorDiagnostic extends DiagnosticContributor { static final invalidGeneratedPartFixKind = FixKind(errorCode.name, 200, 'Fix generated part directive'); static final unnecessaryGeneratedPartFixKind = FixKind(errorCode.name, 200, 'Remove generated part directive'); - static final _debugFlagPattern = RegExp(r'debug:.*\bover_react_boilerplate\b'); + static final _debugCommentPattern = getDebugCommentPattern('over_react_boilerplate'); // TODO(nullsafety) come back and clean up fields @@ -89,7 +91,7 @@ class BoilerplateValidatorDiagnostic extends DiagnosticContributor { /// Also returns whether the component has valid over_react declarations, which is useful in determining whether to /// validate the generated part directive. Future _computeBoilerplateErrors(ResolvedUnitResult result, DiagnosticCollector collector) async { - final debugMatch = _debugFlagPattern.firstMatch(result.content!); + final debugMatch = _debugCommentPattern.firstMatch(result.content!); final debug = debugMatch != null; if (debug) { collector.addError(debugCode, result.location(offset: debugMatch!.start, end: debugMatch.end), diff --git a/tools/analyzer_plugin/lib/src/diagnostic/exhaustive_deps.dart b/tools/analyzer_plugin/lib/src/diagnostic/exhaustive_deps.dart new file mode 100644 index 000000000..bad24a0c4 --- /dev/null +++ b/tools/analyzer_plugin/lib/src/diagnostic/exhaustive_deps.dart @@ -0,0 +1,2311 @@ +// Adapted from https://github.com/facebook/react/blob/main@%7B2020-10-16%7D/packages/eslint-plugin-react-hooks/src/ExhaustiveDeps.js +// +// MIT License +// +// Copyright (c) Facebook, Inc. and its affiliates. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import 'dart:math'; + +import 'package:analyzer/dart/analysis/results.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/syntactic_entity.dart'; +import 'package:analyzer/dart/ast/token.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; +import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer_plugin/protocol/protocol_common.dart' show Location; +import 'package:analyzer_plugin/utilities/range_factory.dart'; +import 'package:collection/collection.dart' show IterableExtension; +import 'package:over_react_analyzer_plugin/src/diagnostic/analyzer_debug_helper.dart'; +import 'package:over_react_analyzer_plugin/src/diagnostic_contributor.dart'; +import 'package:over_react_analyzer_plugin/src/indent_util.dart'; +import 'package:over_react_analyzer_plugin/src/util/ast_util.dart'; +import 'package:over_react_analyzer_plugin/src/util/function_components.dart'; +import 'package:over_react_analyzer_plugin/src/util/pretty_print.dart'; +import 'package:over_react_analyzer_plugin/src/util/range.dart'; +import 'package:over_react_analyzer_plugin/src/util/react_types.dart'; +import 'package:over_react_analyzer_plugin/src/util/util.dart'; +import 'package:over_react_analyzer_plugin/src/util/weak_map.dart'; + +const _desc = r'Verifies the list of dependencies for React Hooks like useEffect and similar.'; +// +const _details = r''' +Ported/forked from the JS eslint-plugin-react-hooks `react-hooks/exhaustive-deps` rule +([info from the React docs](https://reactjs.org/docs/hooks-effect.html#:~:text=If%20you%20use%20this%20optimization%2C%20make%20sure%20the%20array%20includes), + [package](https://www.npmjs.com/package/eslint-plugin-react-hooks), + [source](https://github.com/facebook/react/blob/main@%7B2020-10-16%7D/packages/eslint-plugin-react-hooks/src/ExhaustiveDeps.js)), +this Dart lint aims to provide parity with the dev experience of the JS lint rule, with some tweaks to work better with Dart and its flavor of React APIs. + +--- + +**DO** include dependencies for all values from the component scope (such as props and state) that change over time and that are used by the effect. See [this note in thhe React docs](https://reactjs.org/docs/hooks-effect.html#:~:text=If%20you%20use%20this%20optimization%2C%20make%20sure%20the%20array%20includes) for more info. + +**GOOD:** +```dart +final initialCount = props.initialCount; + +final count = useCount(0); + +final resetCount = useCallback(() { + count.set(initialCount) +}, [initialCount]); + +useEffect(() { + props.onCountChange(count.value); +}, [count.value, props.onCountChange]); +``` + +**BAD:** +```dart +final initialCount = props.initialCount; + +final count = useCount(0); + +final resetCount = useCallback(() { + count.set(initialCount) +// Missing `initialCount`; this callback will reference a stale value. +}, []); + +useEffect(() async { + props.onCountChange(count.value); +// `count` will be a new StateHook instance each render, so this effect will always run. +// We want just `count.value`. +}, [count, props.onCountChange]); +``` + +--- + +Custom hooks can be validated (assuming the first argument is the callback and the second argument is +the dependencies list) by configuring a name regular expression in `analysis_options.yaml`: +```yaml +over_react: + exhaustive_deps: + additional_hooks: ^(useMyEffect1|useMyEffect2)$ +``` +We suggest to use this option __very sparingly, if at all__. Generally saying, we recommend most custom Hooks to +not use the dependencies argument, and instead provide a higher-level API that is more focused around a specific +use case. + +'''; +// + +/// A diagnostic that validates the dependencies of React hooks, such as `useEffect`, `useMemo`, and `useCallback`. +/// +/// Ported/forked from the JS eslint-plugin-react-hooks `react-hooks/exhaustive-deps` rule +/// ([info from the React docs](https://reactjs.org/docs/hooks-effect.html#:~:text=If%20you%20use%20this%20optimization%2C%20make%20sure%20the%20array%20includes), +/// [package](https://www.npmjs.com/package/eslint-plugin-react-hooks), +/// [source](https://github.com/facebook/react/blob/main@%7B2020-10-16%7D/packages/eslint-plugin-react-hooks/src/ExhaustiveDeps.js) +/// ), this Dart lint aims to provide parity with the dev experience of the JS lint rule, with some behavior altered +/// where it makes sense to accommodate differences between JavaScript, Dart, and their respective React APIs. +/// +/// Custom hooks can be validated (assuming the first argument is the callback and the second argument is +/// the dependencies list) by configuring a name regular expression in `analysis_options.yaml`: +/// ```yaml +/// over_react: +/// exhaustive_deps: +/// additional_hooks: ^(useMyEffect1|useMyEffect2)$ +/// ``` +/// We suggest to use this option __very sparingly, if at all__. Generally saying, we recommend most custom Hooks to +/// not use the dependencies argument, and instead provide a higher-level API that is more focused around a specific +/// use case. +/// +/// ## Notable behavioral differences: +/// +/// ### Detecting stable/unstable hook values (useState, useReducer, etc.) +/// +/// JavaScript supports destructuring, whereas Dart does not. Since `useState` and similar hooks are typically +/// destructured in JS, but can't be in Dart without an unwieldy amount of code, logic was added to accommodate +/// usages of the non-destructured hook object: +/// +/// ```dart +/// final count = useState(0); +/// // No dependency needed for `.set` or `.setWithUpdater`. +/// final resetCount = useCallback(() => count.set(0), []); +/// // A dependency is needed for `.value`. +/// useEffect(() { +/// print('Count changed to: ${count.value}'); +/// }, [count.value]); +/// ``` +/// +/// ### `this` and calls to function props +/// +/// Invoking functions on an object in JavaScript populates `this` with the object the function was called on +/// (unless `.bind` is used), whereas Dart's' `this` is defined lexically and is the same regardless of how the +/// function is called. +/// +/// This means that in JS, a call to `props.callback()` can't depend on just `props.callback`, and needs to also +/// depend on `props` since it's `props` that will be passed as `this` when `callback` is invoked. To work around this, +/// it's recommended to destructure these callbacks so that when they're called, we're not relying on the parent object +/// and invalidating the dependencies on every render: +/// +/// ```js +/// // Instead of: +/// useEffect(() => props.callback(), [props]); +/// // ...it's recommended to do: +/// const {callback} = props; +/// useEffect(() => callback(), [callback]); +/// ``` +/// +/// In Dart, we don't have that same problem, and can depend on just `props.callback`: +/// +/// ```dart +/// // This is fine: +/// useEffect(() => props.callback(), [props.callback]); +/// // ...and this is also fine: +/// final callback = props.callback; +/// useEffect(() => callback(), [callback]); +/// ``` +/// +/// ### Hoisting +/// JavaScript supports hoisting of functions and variables (meaning they can be used before they're defined), +/// whereas Dart does not. +/// +/// As a result, we can make some assumptions and perform certain fixes (like changing a local function into a +/// function variable wrapped in useCallback) that wouldn't be possible in JavaScript without more thorough checks. +/// +/// ## Notable implementation differences: +/// The JavaScript implementation relies heavily on [ScopeManager](https://eslint.org/docs/developer-guide/scope-manager-interface) +/// APIs, which Dart's analyzer package doesn't have a 1:1 equivalent for. These parts have been refactored to use the +/// element model (resolved AST) and other information in the AST instead. +/// +/// The JavaScript implementation ran synchronously in a visitor, whereas the Dart implementation needed to be async +/// in order to use ErrorCollector APIs. So, it synchronously finds calls to validate, and then asynchronously +/// processes them. +/// +/// Dart language features that needed additional logic to support: +/// - Cascades +/// - Method invocations and nested property accesses (they come in a few different forms, and have ASTs structures +/// that can have different nesting than in JS) +/// - Collection elements (spreads, if/for-elements, etc.) +/// - Constant variables/expressions +class ExhaustiveDeps extends DiagnosticContributor { + static final _debugCommentPattern = getDebugCommentPattern('over_react_exhaustive_deps'); + + @DocsMeta(_desc, details: _details) + static const code = DiagnosticCode( + 'over_react_exhaustive_deps', + "{0}", + AnalysisErrorSeverity.ERROR, + AnalysisErrorType.STATIC_WARNING, + url: 'https://reactjs.org/docs/hooks-rules.html', + ); + + @override + List get codes => [code]; + + static final fixKind = FixKind(code.name, 200, "{0}"); + + final RegExp? additionalHooksPattern; + + ExhaustiveDeps({this.additionalHooksPattern}); + + // (ported) Should be shared between visitors. + // For now, these aren't static variables, since it's unclear whether that would help + // or cause issues in this Dart implementation based on whether AST nodes and elements are or aren't reused. + // From a brief investigation, WeakMap doesn't seem to result in any worse performance than Map here. + /// A mapping from setState references to setState declarations + final setStateCallSites = WeakMap(); + final stableKnownValueCache = WeakMap(); + final functionWithoutCapturedValueCache = WeakMap(); + + // Variables referenced in helper methods, which are easier to temporarily set on the class than pass around. + // Since these variables can't be non-nullable, expose non-nullable getters so that we don't have to use `!` everywhere, + // and so that we get better error messages if they're ever null. + ResolvedUnitResult? _result; + DiagnosticCollector? _collector; + AnalyzerDebugHelper? _debugHelper; + + StateError _uninitializedError(String name) => + StateError('$name is null. Check computeErrors to make sure it is initialized.'); + + ResolvedUnitResult get result => _result ?? (throw _uninitializedError('result')); + + DiagnosticCollector get collector => _collector ?? (throw _uninitializedError('collector')); + + AnalyzerDebugHelper get debugHelper => _debugHelper ?? (throw _uninitializedError('debugHelper')); + + @override + Future computeErrors(result, collector) async { + try { + _result = result; + _collector = collector; + _debugHelper = AnalyzerDebugHelper(result, collector, enabled: _debugCommentPattern.hasMatch(result.content!)); + + final reactiveHookCallbacks = []; + result.unit!.accept(_ExhaustiveDepsVisitor( + additionalHooks: additionalHooksPattern, + onReactiveHookCallback: reactiveHookCallbacks.add, + )); + + for (final callback in reactiveHookCallbacks) { + await _handleReactiveHookCallback(callback); + } + } finally { + _result = null; + _collector = null; + _debugHelper = null; + } + } + + /// Adds a debug hint with a string from [computeString] at [location], + /// which can be a [Location], [AstNode], or int offset. + /// + /// Takes in a function instead of a string so that the computation can be skipped + /// if debug hints aren't enabled, as a performance optimization. + void debug(String Function() computeString, dynamic location) { + if (!debugHelper.enabled) return; + Location _location; + if (location is Location) { + _location = location; + } else if (location is AstNode) { + _location = result.locationFor(location); + } else if (location is int) { + _location = result.location(offset: location); + } else { + throw ArgumentError.value(location, 'location'); + } + debugHelper.logWithLocation(computeString(), _location); + } + + String getSource(SyntacticEntity entity) => result.content!.substring(entity.offset, entity.end); + + void reportProblem({required AstNode node, required String message}) => + collector.addError(ExhaustiveDeps.code, result.locationFor(node), errorMessageArgs: [message]); + + Future _handleReactiveHookCallback(ReactiveHookCallbackInfo info) async { + final callback = info.callback; + final reactiveHook = info.reactiveHook; + final reactiveHookName = info.reactiveHookName; + final declaredDependenciesNode = info.declaredDependenciesNode; + final isEffect = info.isEffect; + + // Check the declared dependencies for this reactive hook. If there is no + // second argument then the reactive callback will re-run on every render. + // So no need to check for dependency inclusion. + if (declaredDependenciesNode == null && !isEffect) { + // These are only used for optimization. + if (reactiveHookName == 'useMemo' || reactiveHookName == 'useCallback') { + // TODO(ported): Can this have a suggestion? + reportProblem( + node: reactiveHook, + message: "React Hook $reactiveHookName does nothing when called with " + "only one argument. Did you forget to pass a list of " + "dependencies?", + ); + } + return; + } + + if (callback is FunctionExpression) { + await visitFunctionWithDependencies(callbackFunction: callback, info: info); + return; // Handled + } else if (callback is Identifier) { + if (declaredDependenciesNode == null) { + // No deps, no problems. + return; // Handled + } + // The function passed as a callback is not written inline. + // But perhaps it's in the dependencies array? + if (declaredDependenciesNode is ListLiteral && + declaredDependenciesNode.elements.whereType().any( + (el) => el.name == callback.name, + )) { + // If it's already in the list of deps, we don't care because + // this is valid regardless. + return; // Handled + } + // We'll do our best effort to find it, complain otherwise. + final declaration = callback.staticElement?.declaration; + if (declaration == null) { + // If it's not in scope, we don't care. + return; // Handled + } + // The function passed as a callback is not written inline. + // But it's defined somewhere in the render scope. + // We'll do our best effort to find and check it, complain otherwise. + final function = lookUpFunction(declaration, callback.root); + if (function != null) { + // effectBody() {...}; + // // or + // final effectBody = () {...}; + // useEffect(() => { ... }, []); + await visitFunctionWithDependencies(callbackFunction: function, info: info); + return; // Handled + } + // Unhandled + } else { + // useEffect(generateEffectBody(), []); + reportProblem( + node: reactiveHook, + message: "React Hook $reactiveHookName received a function whose dependencies " + "are unknown. Pass an inline function instead.", + ); + return; // Handled + } + + // Something unusual. Fall back to suggesting to add the body itself as a dep. + final callbackName = callback.name; + await collector.addErrorWithFix( + ExhaustiveDeps.code, + result.locationFor(reactiveHook), + errorMessageArgs: [ + // ignore: prefer_adjacent_string_concatenation + "React Hook $reactiveHookName has a missing dependency: '$callbackName'. " + + "Either include it or remove the dependency list." + ], + fixKind: ExhaustiveDeps.fixKind, + fixMessageArgs: ["Update the dependencies list to be: [$callbackName]"], + computeFix: () => buildGenericFileEdit(result, (e) { + e.addSimpleReplacement(range.node(declaredDependenciesNode), "[$callbackName]"); + }), + ); + } + +// Visitor for both function expressions and arrow function expressions. + Future visitFunctionWithDependencies({ + required ReactiveHookCallbackInfo info, + required FunctionExpression callbackFunction, + }) async { + final reactiveHook = info.reactiveHook; + final reactiveHookName = info.reactiveHookName; + final declaredDependenciesNode = info.declaredDependenciesNode; + final isEffect = info.isEffect; + + final rootNode = callbackFunction.root; + + if (isEffect && callbackFunction.body.isAsynchronous) { + reportProblem( + node: callbackFunction, + message: "Effect callbacks are synchronous to prevent race conditions. " + "Put the async function inside:\n\n" + 'useEffect(() {\n' + ' fetchData() async {\n' + ' // You can await here\n' + ' final response = await myAPI.getData(someId);\n' + ' // ...\n' + ' }\n' + ' fetchData();\n' + "}, [someId]); // Or [] if effect doesn't need props or state\n\n" + 'Learn more about data fetching with Hooks: https://reactjs.org/link/hooks-data-fetching', + ); + } + + // Find all our "pure scopes". On every re-render of a component these + // pure scopes may have changes to the variables declared within. So all + // variables used in our reactive hook callback but declared in a pure + // scope need to be listed as dependencies of our reactive hook callback. + // + // According to the rules of React you can't read a mutable value in pure + // scope. We can't enforce this in a lint so we trust that all variables + // declared outside of pure scope are indeed frozen. + + // Pure scopes include all scopes from the parent scope of the callback + // to the first function scope (which will be either the component/render function or a custom hook, + // since hooks can only be called from the top level). + + // TODO(greg) clean this up + final componentOrCustomHookFunctionBody = getClosestFunctionComponentOrHookBody(callbackFunction); + final componentOrCustomHookFunction = componentOrCustomHookFunctionBody?.parentExpression; + assert(componentOrCustomHookFunction == null || componentOrCustomHookFunction != callbackFunction); + + final componentOrCustomHookFunctionElement = componentOrCustomHookFunction?.declaredElement; + + debug(() => 'componentOrCustomHookFunctionElement: ${componentOrCustomHookFunctionElement?.debugString}', + componentOrCustomHookFunction?.offset ?? 0); + + bool isDeclaredInPureScope(Element element) => + // TODO(greg) is this function even valid when this is null? + componentOrCustomHookFunctionElement != null && + // Use thisOrAncestorOfType on the parent since `element` may itself be an ExecutableElement (e.g., local functions). + element.enclosingElement?.thisOrAncestorOfType() == componentOrCustomHookFunctionElement; + + bool isProps(Element e) { + return e.name == 'props' && + e.enclosingElement == componentOrCustomHookFunctionElement && + lookUpParameter(e, rootNode)?.parent == componentOrCustomHookFunction?.parameters; + } + + // Returns whether [element] is a variable assigned to a prop value. + bool isPropVariable(Element element) { + if (!isDeclaredInPureScope(element)) return false; + final variable = lookUpVariable(element, rootNode); + if (variable == null) return false; + + final initializer = variable.initializer; + if (initializer == null) return false; + final initializerTargetElement = + getSimpleTargetAndPropertyName(initializer, allowMethodInvocation: false)?.item1.staticElement; + + return initializerTargetElement != null && isProps(initializerTargetElement); + } + + // uiFunction((props), { + // // Pure scope 2 + // var renderVar; + // { + // // Pure scope 1 + // var blockVar; + // + // useEffect(() { + // // This effect callback block is , the function we're visiting + // }); + // } + // }, ...) + + // Next we'll define a few helpers that helps us + // tell if some values don't have to be declared as deps. + + // Some are known to be stable based on Hook calls. + // const [state, setState] = useState() / React.useState() + // ^^^ true for this reference + // const [state, dispatch] = useReducer() / React.useReducer() + // ^^^ true for this reference + // const ref = useRef() + // ^^^ true for this reference + // False for everything else. + bool isStableKnownHookValue(Identifier reference) { + VariableDeclaration? lookUpVariableDeclarationForReference() { + final referenceElement = reference.staticElement; + return referenceElement == null + ? null + : lookUpDeclaration(referenceElement, reference.root)?.tryCast(); + } + + // Check whether this reference is only used to a stable hook property. + { + final parent = reference.parent; + final stableHookInfo = parent != null ? getStableHookMethodInfo(parent) : null; + if (stableHookInfo != null && stableHookInfo.target == reference) { + if (stableHookInfo.isStateSetterMethod) { + final declaration = lookUpVariableDeclarationForReference(); + if (declaration != null) { + setStateCallSites.set(reference, declaration); + } + } + + return true; + } + } + + // Look up the variable declaration. + // Don't worry about functions, since they're handled in memoizedIsFunctionWithoutCapturedValues. + final declaration = lookUpVariableDeclarationForReference(); + if (declaration == null) return false; + + var init = declaration.initializer; + if (init == null) return false; + + Expression unwrap(Expression expr) { + if (expr is AsExpression) return expr.expression; + if (expr is ParenthesizedExpression) return expr.expression; + return expr; + } + + init = unwrap(init); + + // Detect primitive constants + // const foo = 42 + + // Note: in the JS version of this plugin, there was: + // > This might happen if variable is declared after the callback. + // but that isn't possible in Dart, so we can omit that logic. + + if (declaration.isConst || (declaration.isFinal && !declaration.isLate && isAConstantValue(init))) { + // Definitely stable + return true; + } + + // Detect known Hook calls + // const [_, setState] = useState() + + // Handle hook tear-offs + // final setCount = useCount(1).set; + if (init is PropertyAccess || init is PrefixedIdentifier) { + final stableHookInfo = getStableHookMethodInfo(init); + if (stableHookInfo != null) { + if (stableHookInfo.isStateSetterMethod) { + setStateCallSites.set(reference, declaration); + } + return true; + } + } + + // useRef() return value is stable. + if (init.tryCast()?.function.tryCast()?.name == 'useRef') { + return true; + } + + // By default assume it's dynamic. + return false; + } + + // Remember such values. Avoid re-running extra checks on them. + final memoizedIsStableKnownHookValue = isStableKnownHookValue.memoizeWithWeakMap(stableKnownValueCache); + + // Some are just functions that don't reference anything dynamic. + bool isFunctionWithoutCapturedValues(Element resolved) { + final fnNode = lookUpFunction(resolved, rootNode); + + // It's couldn't be looked up, it's either: + // - not a function, and we need to return false + // - one of the following cases which shouldn't ever get hit for the callers of this + // function, since it's only called with elements declared within a pure scope + // that correspond to an identifier reference within the hook callback: + // - a function expression (shouldn't get hit since identifiers can't reference an expression) + // - not in the same file (shouldn't get hit since these aren't declared within a pure scope) + if (fnNode == null) return false; + + // If it's outside the component, it also can't capture values. + if (componentOrCustomHookFunction != null && !componentOrCustomHookFunction.containsRangeOf(fnNode)) return true; + + // Does this function capture any values + // that are in pure scopes (aka render)? + final referencedElements = resolvedReferencesWithin(fnNode.body); + for (final ref in referencedElements) { + if (isDeclaredInPureScope(ref.staticElement!) && + // Stable values are fine though, + // although we won't check functions deeper. + !memoizedIsStableKnownHookValue(ref)) { + return false; + } + } + // If we got here, this function doesn't capture anything + // from render--or everything it captures is known stable. + return true; + } + + final memoizedIsFunctionWithoutCapturedValues = + isFunctionWithoutCapturedValues.memoizeWithWeakMap(functionWithoutCapturedValueCache); + + // These are usually mistaken. Collect them. + final currentRefsInEffectCleanup = {}; + + // Is this reference inside a cleanup function for this effect node? + bool isInsideEffectCleanup(AstNode reference) => callbackFunction.body.returnExpressions + .whereType() + .any((cleanupFunction) => cleanupFunction.body.containsRangeOf(reference)); + + // Get dependencies from all our resolved references in pure scopes. + // Key is dependency string, value is whether it's stable. + final dependencies = {}; + final optionalChains = {}; + + // The original implementation needs to recurse to process references in child scopes, + // but we can process all descendant references regardless of scope in one go. + for (final reference in resolvedReferencesWithin(callbackFunction)) { + // debug( + // 'reference.staticElement.ancestors: \n${prettyPrint(reference.staticElement.ancestors.map(elementDebugString).toList())}', + // reference); + + // If this reference is not resolved or it is not declared in a pure + // scope then we don't care about this reference. + // + // Note that with the implementation of `resolvedReferencesWithin`, + // this is also necessary to filter out references to properties on object. + + final referenceElement = reference.staticElement; + if (referenceElement == null) continue; + + if (!isDeclaredInPureScope(referenceElement)) { + continue; + } + + // Narrow the scope of a dependency if it is, say, a member expression. + // Then normalize the narrowed dependency. + final dependencyNode = getDependency(reference); + final isUsedAsCascadeTarget = dependencyNode.parent?.tryCast()?.target == dependencyNode; + final dependency = analyzePropertyChain( + dependencyNode, + optionalChains, + isInvocationAllowedForNode: true, + ); + debug(() { + return prettyPrint({ + 'dependency': dependency, + 'dependencyNode': '${dependencyNode.runtimeType} $dependencyNode', + 'reference': '${reference.runtimeType} $reference', + 'isUsedAsCascadeTarget': isUsedAsCascadeTarget, + }); + }, dependencyNode); + + // Accessing ref.current inside effect cleanup is bad. + if ( + // We're in an effect... + isEffect && + // ... and this look like accessing .current... + dependencyNode is Identifier && + getNonCascadedPropertyBeingAccessed(dependencyNode.parent)?.name == 'current' && + // ...in a cleanup function or below... + isInsideEffectCleanup(reference)) { + currentRefsInEffectCleanup[dependency] = _RefInEffectCleanup( + reference: reference, + referenceElement: referenceElement, + ); + } + + // Add the dependency to a map so we can make sure it is referenced + // again in our dependencies array. Remember whether it's stable. + dependencies.putIfAbsent(dependency, () { + return _Dependency( + isStable: + memoizedIsStableKnownHookValue(reference) || memoizedIsFunctionWithoutCapturedValues(referenceElement), + references: [], + ); + }) + // Note that for the the `state.set` case, the reference is still `state`. + ..references.add(reference) + ..isUsedSomewhereAsCascadeTarget |= isUsedAsCascadeTarget; + } + + // Warn about accessing .current in cleanup effects. + currentRefsInEffectCleanup.forEach( + (dependency, _entry) { + final reference = _entry.reference; + + // Is React managing this ref or us? + // Let's see if we can find a .current assignment. + var foundCurrentAssignment = false; + // This only finds references in the same file, but that's okay for our purposes. + for (final reference in allResolvedReferencesTo(reference.staticElement!, reference.root)) { + final parent = reference.parent; + if (parent != null && + // ref.current + getNonCascadedPropertyBeingAccessed(parent)?.name == 'current' && + // ref.current = + parent.parent?.tryCast()?.leftHandSide == parent) { + foundCurrentAssignment = true; + break; + } + } + // We only want to warn about React-managed refs. + if (foundCurrentAssignment) { + return; + } + reportProblem( + node: reference.parent!, + message: "The ref value '$dependency.current' will likely have " + "changed by the time this effect cleanup function runs. If " + "this ref points to a node rendered by React, copy " + "'$dependency.current' to a variable inside the effect, and " + "use that variable in the cleanup function.", + ); + }, + ); + + // Warn about assigning to variables in the outer scope. + // Those are usually bugs. + final staleAssignments = {}; + void reportStaleAssignment(Expression writeExpr, String key) { + if (staleAssignments.contains(key)) { + return; + } + staleAssignments.add(key); + reportProblem( + node: writeExpr, + message: "Assignments to the '$key' variable from inside React Hook " + "${getSource(reactiveHook)} will be lost after each " + "render. To preserve the value over time, store it in a useRef " + "Hook and keep the mutable value in the '.current' property. " + "Otherwise, you can move this variable directly inside " + "${getSource(reactiveHook)}.", + ); + } + + // Remember which deps are stable and report bad usage first. + final stableDependencies = {}; + dependencies.forEach((key, dep) { + if (dep.isStable) { + stableDependencies.add(key); + } + for (final reference in dep.references) { + final parent = reference.parent; + // TODO(greg) make a utility to check for assignments + if (parent is AssignmentExpression && parent.leftHandSide == reference) { + reportStaleAssignment(parent, key); + } + } + }); + + if (staleAssignments.isNotEmpty) { + // The intent isn't clear so we'll wait until you fix those first. + return; + } + + if (declaredDependenciesNode == null) { + // Check if there are any top-level setState() calls. + // Those tend to lead to infinite loops. + String? setStateInsideEffectWithoutDeps; + dependencies.forEach((key, _entry) { + final references = _entry.references; + if (setStateInsideEffectWithoutDeps != null) { + return; + } + for (final reference in references) { + if (setStateInsideEffectWithoutDeps != null) { + continue; + } + + final isSetState = setStateCallSites.has(reference); + if (!isSetState) { + continue; + } + + final isDirectlyInsideEffect = reference.thisOrAncestorOfType() == callbackFunction.body; + if (isDirectlyInsideEffect) { + // TODO(ported): we could potentially ignore early returns. + setStateInsideEffectWithoutDeps = key; + } + } + }); + if (setStateInsideEffectWithoutDeps != null) { + final suggestedDependencies = collectRecommendations( + dependencies: dependencies, + declaredDependencies: [], + stableDependencies: stableDependencies, + externalDependencies: {}, + isEffect: true, + ).suggestedDependencies; + await collector.addErrorWithFix( + code, + result.locationFor(reactiveHook), + errorMessageArgs: [ + // ignore: no_adjacent_strings_in_list + "React Hook $reactiveHookName contains a call to '$setStateInsideEffectWithoutDeps'. " + "Without a list of dependencies, this can lead to an infinite chain of updates. " + "To fix this, pass [" + "${suggestedDependencies.join(', ')}" + "] as a second argument to the $reactiveHookName Hook.", + ], + fixKind: fixKind, + fixMessageArgs: ['Add dependencies list: [${suggestedDependencies.join(', ')}]'], + computeFix: () => buildGenericFileEdit(result, (builder) { + builder.addSimpleInsertion(callbackFunction.end, ', [${suggestedDependencies.join(', ')}]'); + }), + ); + } + return; + } + + final declaredDependencies = <_DeclaredDependency>[]; + final externalDependencies = {}; + if (declaredDependenciesNode is! ListLiteral) { + // If the declared dependencies are not an array expression then we + // can't verify that the user provided the correct dependencies. Tell + // the user this in an error. + reportProblem( + node: declaredDependenciesNode, + message: "React Hook ${getSource(reactiveHook)} was passed a " + 'dependency list that is not a list literal. This means we ' + "can't statically verify whether you've passed the correct " + 'dependencies.', + ); + } else { + for (final _declaredDependencyNode in declaredDependenciesNode.elements) { + String? invalidType; + if (_declaredDependencyNode is SpreadElement) { + invalidType = 'a spread element'; + } else if (_declaredDependencyNode is IfElement) { + invalidType = "an 'if' element"; + } else if (_declaredDependencyNode is ForElement) { + invalidType = "a 'for' element"; + } else if (_declaredDependencyNode is! Expression) { + // This should be unreachable at the time of writing, + // since all other CollectionElement subtypes are handled + invalidType = "a non-expression (${_declaredDependencyNode.runtimeType})"; + } + if (invalidType != null) { + reportProblem( + node: _declaredDependencyNode, + message: "React Hook ${getSource(reactiveHook)} has $invalidType " + "in its dependency list. This means we can't " + "statically verify whether you've passed the " + 'correct dependencies.', + ); + continue; + } + + // New variable since the checks above guarantee it's an Expression, but don't type promote it. + final declaredDependencyNode = _declaredDependencyNode as Expression; + + // Try to normalize the declared dependency. If we can't then an error + // will be thrown. We will catch that error and report an error. + String declaredDependency; + try { + declaredDependency = analyzePropertyChain( + declaredDependencyNode, + null, + // While invocations are allowed in certain cases when using dependencies, + // they're not allowed in the dependency list. + isInvocationAllowedForNode: false, + ); + } on UnsupportedNodeTypeException catch (_) { + if (declaredDependencyNode is SimpleStringLiteral && dependencies.containsKey(declaredDependencyNode.value)) { + reportProblem( + node: declaredDependencyNode, + message: "The ${declaredDependencyNode.toSource()} literal is not a valid dependency " + "because it never changes." + " Did you mean to include ${declaredDependencyNode.value} in the list instead?", + ); + } else if (isAConstantValue(declaredDependencyNode)) { + // Provide slightly improved wording in the case of literals. + // Don't forget that literals aren't always constant, so this needs to stay inside a isConstantValue check. + if (declaredDependencyNode is Literal) { + reportProblem( + node: declaredDependencyNode, + message: "The ${declaredDependencyNode.toSource()} literal is not a valid dependency " + "because it never changes. You can safely remove it.", + ); + } else { + reportProblem( + node: declaredDependencyNode, + message: "The '${declaredDependencyNode.toSource()}' expression is not a valid dependency " + "because it never changes. You can safely remove it.", + ); + } + } else { + reportProblem( + node: declaredDependencyNode, + message: "React Hook ${getSource(reactiveHook)} has a " + "complex expression in the dependency list. " + 'Extract it to a separate variable so it can be statically checked.', + ); + } + + continue; + } + + Expression? maybeID = declaredDependencyNode; + while (maybeID is PropertyAccess || maybeID is PrefixedIdentifier) { + if (maybeID is PropertyAccess) { + maybeID = maybeID.target; + } else { + maybeID = (maybeID as PrefixedIdentifier).prefix; + } + } + final enclosingElement = maybeID.tryCast()?.staticElement?.enclosingElement; + final isDeclaredInComponent = + enclosingElement != null && enclosingElement == componentOrCustomHookFunctionElement; + + // Add the dependency to our declared dependency map. + declaredDependencies.add(_DeclaredDependency( + declaredDependency, + declaredDependencyNode, + debugEnclosingElement: maybeID.tryCast()?.staticElement?.enclosingElement, + )); + + if (!isDeclaredInComponent) { + externalDependencies.add(declaredDependency); + } + } + } + + debug(() { + return prettyPrint({ + 'dependencies': dependencies, + 'declaredDependencies': declaredDependencies, + 'stableDependencies': stableDependencies, + 'externalDependencies': externalDependencies, + 'isEffect': isEffect, + }); + }, callbackFunction.body.offset); + + final recommendations = collectRecommendations( + dependencies: dependencies, + declaredDependencies: declaredDependencies, + stableDependencies: stableDependencies, + externalDependencies: externalDependencies, + isEffect: isEffect, + ); + final duplicateDependencies = recommendations.duplicateDependencies; + final missingDependencies = recommendations.missingDependencies; + final unnecessaryDependencies = recommendations.unnecessaryDependencies; + + var suggestedDeps = recommendations.suggestedDependencies; + + final problemCount = duplicateDependencies.length + missingDependencies.length + unnecessaryDependencies.length; + + if (problemCount == 0) { + // If nothing else to report, check if some dependencies would + // invalidate on every render. + final constructions = scanForConstructions( + declaredDependencies: declaredDependencies, + declaredDependenciesNode: declaredDependenciesNode, + callbackNode: callbackFunction, + ); + debug(() => 'constructions: $constructions', callbackFunction.body.offset); + for (final _construction in constructions) { + final construction = _construction.declaration; + final constructionName = _construction.declarationElement.name; + final depType = _construction.depType; + + if (_DepType.hookObjects.contains(depType)) { + final allKnownUnstableMembers = HookConstants.knownUnstableMembersByDepType[depType]!; + + final declaredDependencyNode = _construction.declaredDependency.node; + final declaredDepSource = getSource(declaredDependencyNode); + + // Conditionally suggest value or removing the dep based on whether unstable values (e.g., `stateHook.value`) + // are used. + // + // This check isn't perfect, but should handle most cases. + // + // Dependencies have `?.` normalized to `.`, but they may still be accessing properties or calling methods. + // For example: `stateHook.set.call`, `stateHook.value()`. So, use regexes with word boundaries. + + final dependencyPattern = RegExp(r'^' + RegExp.escape(declaredDepSource) + r'\b'); + RegExp dependencyMemberPattern(String memberName) => + RegExp(r'^' + RegExp.escape(declaredDepSource) + r'\.' + RegExp.escape(memberName) + r'\b'); + bool isDependencyUsage(String dependency) => dependencyPattern.hasMatch(dependency); + + final allDependenciesUsingHook = dependencies.keys.where(isDependencyUsage).toSet(); + final stableDependenciesUsingHook = allDependenciesUsingHook.where(stableDependencies.contains).toSet(); + final unstableDependenciesUsingHook = allDependenciesUsingHook.difference(stableDependenciesUsingHook); + + final usesWholeValue = unstableDependenciesUsingHook.contains(declaredDepSource); + final usesOnlyStableValues = unstableDependenciesUsingHook.isEmpty; + + // Don't suggest using the dependencies themselves, since they might include sub-properties, + // which may or may not the right thing to depend on. E.g., (`stateHook.object.getterThatReturnsNewInstanceEveryTime`). + // Instead, recommend the unstable members themselves, and if the user wants to drill down further, they can + // after applying the suggestion. + final knownUnstableMembersUsed = allKnownUnstableMembers + .where((member) => unstableDependenciesUsingHook.any(dependencyMemberPattern(member).hasMatch)) + .toList(); + final suggestedUnstableMemberDependencies = + knownUnstableMembersUsed.map((m) => '$declaredDepSource.$m').toList(); + + final messageBuffer = StringBuffer() + ..write("The '$declaredDepSource' $depType") + ..write(" makes the dependencies of React Hook ${getSource(reactiveHook)}") + ..write(" change every render, and should not itself be a dependency."); + if (stableDependenciesUsingHook.isNotEmpty) { + messageBuffer + ..write(" Since ") + ..write(joinEnglish(stableDependenciesUsingHook.map((d) => "'$d'"))) + ..write(stableDependenciesUsingHook.length == 1 ? " is" : " are") + ..write(" stable across renders, no dependencies are required to use") + ..write(stableDependenciesUsingHook.length == 1 ? " it" : " them") + ..write(usesOnlyStableValues ? ", and this dependency can be safely removed." : '.'); + } + if (suggestedUnstableMemberDependencies.isNotEmpty) { + messageBuffer + ..write(" Since ") + ..write(joinEnglish(suggestedUnstableMemberDependencies.map((d) => "'$d'"))) + ..write(suggestedUnstableMemberDependencies.length == 1 ? " is" : " are") + ..write(" being used, depend on") + ..write(suggestedUnstableMemberDependencies.length == 1 ? " it" : " them") + ..write(" instead."); + } + // TODO(greg) add message like we have elsewhere when using `setWithUpdater` and `.value`. + + if (usesWholeValue) { + // We can't automatically fix in this case, so just warn. + collector.addError( + ExhaustiveDeps.code, + result.locationFor(declaredDependencyNode), + errorMessageArgs: [messageBuffer.toString()], + ); + } else { + await collector.addErrorWithFix( + ExhaustiveDeps.code, + result.locationFor(declaredDependencyNode), + errorMessageArgs: [messageBuffer.toString()], + fixKind: ExhaustiveDeps.fixKind, + fixMessageArgs: [ + if (suggestedUnstableMemberDependencies.isNotEmpty) + "Change the dependency to: ${suggestedUnstableMemberDependencies.join(', ')}" + else + "Remove the dependency on '$declaredDepSource'." + ], + computeFix: () => buildGenericFileEdit(result, (builder) { + if (suggestedUnstableMemberDependencies.isNotEmpty) { + builder.addSimpleReplacement( + range.node(declaredDependencyNode), suggestedUnstableMemberDependencies.join(', ')); + } else { + builder.addDeletion(range.nodeInList2(declaredDependencyNode)); + } + }), + ); + } + continue; + } + + final isUsedOutsideOfHook = _construction.isUsedOutsideOfHook; + final wrapperHook = depType == _DepType.function ? 'useCallback' : 'useMemo'; + + final constructionType = depType == _DepType.function ? 'definition' : 'initialization'; + + final defaultAdvice = "wrap the $constructionType of '$constructionName' in its own $wrapperHook() Hook."; + + final advice = isUsedOutsideOfHook + ? "To fix this, $defaultAdvice" + : "Move it inside the $reactiveHookName callback. Alternatively, $defaultAdvice"; + + final causation = + depType == _DepType.conditional || depType == _DepType.binaryExpression ? 'could make' : 'makes'; + + final message = "The '$constructionName' $depType $causation the dependencies of " + "$reactiveHookName Hook (at line ${result.lineInfo.getLocation(declaredDependenciesNode.offset).lineNumber}) " + "change on every render. $advice"; + + // Note that, unlike the original JS implementation, we handle functions since they aren't hoisted in Dart. + if (isUsedOutsideOfHook && depType == _DepType.function) { + if (construction is FunctionDeclaration) { + await collector.addErrorWithFix( + ExhaustiveDeps.code, + result.locationFor(construction), + errorMessageArgs: [message], + fixKind: ExhaustiveDeps.fixKind, + fixMessageArgs: ["Wrap the $constructionType of '$constructionName' in its own useCallback() Hook."], + computeFix: () => buildGenericFileEdit( + result, + (builder) { + // Replace the return type through the name: + // `void something(arg) {…}` -> `final something = useCallback((arg) {…}, […]);` + // This also preserves generic function expressions. + // `void something(T arg) {…}` -> `final something = useCallback((T arg) {…}, […]);` + builder.addSimpleReplacement(range.startEnd(construction, construction.name), + 'final ${construction.name.name} = useCallback('); + // TODO(ported): ideally we'd gather deps here but it would require + // restructuring the rule code. Note we're + // not adding [] because would that changes semantics. + // Add a placeholder here so there isn't a static error about using useCallback with the wrong number of arguments. + builder.addSimpleInsertion(construction.end, ', [/* FIXME add dependencies */]);'); + }, + ), + ); + continue; + } + // Objects may be mutated after construction, which would make this + // fix unsafe. Functions _probably_ won't be mutated, so we'll + // allow this fix for them. + else if (construction is VariableDeclaration) { + // At the time of writing, this is currently always non-null here, but that isn't guaranteed in null safety + // and may change in the future.. + final constructionInitializer = construction.initializer; + if (constructionInitializer == null) { + collector.addError( + ExhaustiveDeps.code, + result.locationFor(construction), + errorMessageArgs: [message], + ); + } else { + await collector.addErrorWithFix( + ExhaustiveDeps.code, + result.locationFor(construction), + errorMessageArgs: [message], + fixKind: ExhaustiveDeps.fixKind, + fixMessageArgs: ["Wrap the $constructionType of '$constructionName' in its own $wrapperHook() Hook."], + computeFix: () => buildGenericFileEdit( + result, + (builder) { + // TODO(ported): ideally we'd gather deps here but it would require + // restructuring the rule code. Note we're + // not adding [] because would that changes semantics. + if (wrapperHook == 'useMemo') { + builder.addSimpleInsertion(constructionInitializer.offset, '$wrapperHook(() => '); + builder.addSimpleInsertion(constructionInitializer.end, ')'); + } else { + builder.addSimpleInsertion(constructionInitializer.offset, '$wrapperHook('); + // Add a placeholder here so there isn't a static error about using useCallback with the wrong number of arguments. + builder.addSimpleInsertion(constructionInitializer.end, ', [/* FIXME add dependencies */])'); + } + }, + ), + ); + } + continue; + } + } + + // TODO(ported): What if the function needs to change on every render anyway? + // Should we suggest removing effect deps as an appropriate fix too? + collector.addError( + ExhaustiveDeps.code, + result.locationFor(construction), + errorMessageArgs: [message], + ); + } + return; + } + + // Cascades are currently treated as references to the objects themselves. + // This causes problems when unstable hook objects (like StateHook) or props + // are the target of the cascade, since the logic below would recommend adding them + // as dependencies, as opposed to: + // - omitting the dependencies in the case of hook objects + // - depending on more specific properties in the case of props. + // + // Until we have more comprehensive logic to process cascades differently, and ensure + // the value of the cascade expression (i.e., the cascade target) isn't used, + // we'll short circuit at this point and instruct the user to remove cascades so we + // can provide accurate messages and suggestions. + // + // Only do this when there are dependency problems, so that we don't block consumers from + // cascading on declared dependencies. + final dependenciesUsedInCascade = [...missingDependencies, ...unnecessaryDependencies] + .where((d) => dependencies[d]?.isUsedSomewhereAsCascadeTarget ?? false) + .toSet(); + if (dependenciesUsedInCascade.isNotEmpty) { + final messageBuffer = StringBuffer() + ..write("React Hook ${getSource(reactiveHook)} most likely has issues in its dependencies list," + " but the exact problems and recommended fixes could not be be computed" + " since the ${dependenciesUsedInCascade.length == 1 ? 'dependency' : 'dependencies'} ") + ..write(joinEnglish((dependenciesUsedInCascade.toList()..sort()).map((name) => "'$name'"))) + ..write( + dependenciesUsedInCascade.length == 1 ? " is the target of a cascade." : " are the targets of cascades.") + ..write(" Try refactoring to not cascade on ") + ..write(dependenciesUsedInCascade.length == 1 ? 'that dependency' : 'those dependencies') + ..write(" in the callback to get more helpful instructions and potentially a suggested fix."); + + collector.addError( + ExhaustiveDeps.code, + result.locationFor(declaredDependenciesNode), + errorMessageArgs: [messageBuffer.toString()], + ); + return; + } + + // If we're going to report a missing dependency, + // we might as well recalculate the list ignoring + // the currently specified deps. This can result + // in some extra deduplication. We can't do this + // for effects though because those have legit + // use cases for over-specifying deps. + if (!isEffect && missingDependencies.isNotEmpty) { + suggestedDeps = collectRecommendations( + dependencies: dependencies, + declaredDependencies: [], + // Pretend we don't know + stableDependencies: stableDependencies, + externalDependencies: externalDependencies, + isEffect: isEffect, + ).suggestedDependencies; + } + + // Alphabetize the suggestions, but only if deps were already alphabetized. + if (declaredDependencies.map((dep) => dep.key).isSorted) { + suggestedDeps.sort(); + } + + // Most of our algorithm deals with dependency paths with optional chaining stripped. + // This function is the last step before printing a dependency, so now is a good time to + // check whether any members in our path are always used as optional-only. In that case, + // we will use ?. instead of . to concatenate those parts of the path. + String formatDependency(String path) { + final members = path.split('.'); + var finalPath = ''; + for (var i = 0; i < members.length; i++) { + if (i != 0) { + final pathSoFar = members.sublist(0, i + 1).join('.'); + final isOptional = optionalChains[pathSoFar] == true; + finalPath += isOptional ? '?.' : '.'; + } + finalPath += members[i]; + } + return finalPath; + } + + String? getWarningMessage(Iterable deps, String singlePrefix, String label, String fixVerb) { + if (deps.isEmpty) { + return null; + } + return (deps.length > 1 ? '' : singlePrefix + ' ') + + label + + ' ' + + (deps.length > 1 ? 'dependencies' : 'dependency') + + ': ' + + joinEnglish((deps.toList()..sort()).map((name) => "'" + formatDependency(name) + "'").toList()) + + ". Either $fixVerb ${deps.length > 1 ? 'them' : 'it'} or remove the dependency list."; + } + + String? extraWarning; + if (unnecessaryDependencies.isNotEmpty) { + final badRef = unnecessaryDependencies.firstWhereOrNull((key) => key.endsWith('.current')); + if (badRef != null) { + extraWarning = " Mutable values like '$badRef' aren't valid dependencies " + "because mutating them doesn't re-render the component."; + } else if (externalDependencies.isNotEmpty) { + final dep = externalDependencies.first; + // Don't show this warning for things that likely just got moved *inside* the callback + // because in that case they're clearly not referring to globals. + final isDeclaredInCallbackFunction = _hasDeclarationWithName(callbackFunction.body, dep); + if (!isDeclaredInCallbackFunction) { + extraWarning = " Outer scope values like '$dep' aren't valid dependencies " + "because mutating them doesn't re-render the component."; + } + } + } + + // The Dart implementation of this lint removes extra warning around this old behavior: + // // "props.foo()" marks "props" as a dependency because it has + // // a "this" value. This warning can be confusing. + // // So if we're going to show it, append a clarification. + // which is no longer relevant in this implementation since "props.foo()" no longer marks "props" as a dependency. + + if (extraWarning == null && missingDependencies.isNotEmpty) { + // See if the user is trying to avoid specifying a callable prop. + // This usually means they're unaware of useCallback. + final missingCallbackDeps = missingDependencies.where((missingDep) { + // Is this a variable from top scope? + // TODO(greg) can we reuse logic from scanForConstructions here? + final usedDep = dependencies[missingDep]; + if (usedDep == null) return false; + + return usedDep.references.any((ref) { + final element = ref.staticElement; + if (element != null && isPropVariable(element)) { + // foo() + if (ref.parent.tryCast()?.function == ref) { + return true; + } + // foo.call() + final inv = PropertyInvocation.detectClosest(ref); + if (inv != null) { + return inv.target == ref && inv.functionName.name == 'call'; + } + } + + if (element != null && isProps(element)) { + final inv = PropertyInvocation.detectClosest(ref); + if (inv != null) { + final target = inv.target; + // props.foo() + if (target == ref) { + return true; + } + // props.foo.call() + if (inv.functionName.name == 'call' && + target != null && + getSimpleTargetAndPropertyName(target, allowMethodInvocation: false)?.item1 == ref) { + return true; + } + } + } + + return false; + }); + }).toList(); + if (missingCallbackDeps.isNotEmpty) { + extraWarning = " If " + + (missingCallbackDeps.length == 1 + ? "'${formatDependency(missingCallbackDeps.single)}'" + : "any of " + joinEnglish((missingCallbackDeps.toList()..sort()).map((d) => "'$d'").toList(), 'or')) + + " changes too often, " + "find the parent component that defines it " + "and wrap that definition in useCallback."; + } + } + + if (extraWarning == null && missingDependencies.isNotEmpty) { + _SetStateRecommendation? setStateRecommendation; + for (final missingDep in missingDependencies) { + if (setStateRecommendation != null) { + break; + } + final usedDep = dependencies[missingDep]!; + for (final reference in usedDep.references) { + // Try to see if we have setState(someExpr(missingDep)). + for (var maybeCall = reference.parent; + maybeCall != null && maybeCall != componentOrCustomHookFunction?.body; + maybeCall = maybeCall.parent) { + if (maybeCall is InvocationExpression) { + final maybeCallFunction = maybeCall.function; + final maybeCallFunctionName = (maybeCall is MethodInvocation && maybeCall.target != null + ? '${maybeCall.target!.toSource()}.${maybeCall.methodName.name}' + : null) ?? + maybeCallFunction.tryCast()?.name ?? + maybeCallFunction.toSource(); + final correspondingStateVariable = setStateCallSites.getNullableKey(maybeCallFunction.tryCast()) ?? + // This case is necessary for `stateVar.set` calls, since key for setStateCallSites is the `stateVar` identifier, not `set`. + setStateCallSites.getNullableKey(maybeCall.tryCast()?.target.tryCast()); + if (correspondingStateVariable != null) { + if ('${correspondingStateVariable.name.name}.value' == missingDep) { + // setCount(count + 1) + setStateRecommendation = _SetStateRecommendation( + missingDep: missingDep, + setter: maybeCallFunctionName, + form: _SetStateRecommendationForm.updater, + ); + } else if (reference.staticType?.isStateHook ?? false) { + // setCount(count + increment) + setStateRecommendation = _SetStateRecommendation( + missingDep: missingDep, + setter: maybeCallFunctionName, + form: _SetStateRecommendationForm.reducer, + ); + } else { + // Recommend an inline reducer if it's a prop. + final referenceElement = reference.staticElement; + if (referenceElement != null && (isProps(referenceElement) || isPropVariable(referenceElement))) { + setStateRecommendation = _SetStateRecommendation( + missingDep: missingDep, + setter: maybeCallFunctionName, + form: _SetStateRecommendationForm.inlineReducer, + ); + } + } + break; + } + } + } + if (setStateRecommendation != null) { + break; + } + } + } + if (setStateRecommendation != null) { + final setter = setStateRecommendation.setter; + final missingDep = setStateRecommendation.missingDep; + switch (setStateRecommendation.form) { + case _SetStateRecommendationForm.reducer: + extraWarning = " You can also replace multiple useState variables with useReducer " + "if '$setter' needs '$missingDep'."; + break; + case _SetStateRecommendationForm.inlineReducer: + extraWarning = " If '$setter' needs the current value of '$missingDep', " + "you can also switch to useReducer instead of useState and " + "read '$missingDep' in the reducer."; + break; + case _SetStateRecommendationForm.updater: + final updaterArgumentName = missingDep.substring(0, 1); + // This might already by an updater function, if they're referencing the state in the updater. + // TODO(greg) add a better message for this case, telling them to use the arg instead? + final functionalUpdateSetter = setter.endsWith('WithUpdater') ? setter : '${setter}WithUpdater'; + extraWarning = " You can also do a functional update" + " '$functionalUpdateSetter(($updaterArgumentName) => ...)'" + " if you only need '$missingDep' in the '$setter' call."; + break; + } + } + } + + /// Returns Dart source code for a dependencies list containing the given [dependencies], whose elements + /// are Dart source code strings. + /// + /// This method conditionally includes newlines and trailing commas if the existing dependencies list + /// had them. That way, any replacement is less jarring to the user, it's easier to see what + /// changed without reformatting, and the existing style is preserved. + String prettyDependenciesList(Iterable dependencies) { + bool useNewlines; + bool useTrailingComma; + String listElementIndent; + if (declaredDependenciesNode is ListLiteral && declaredDependenciesNode.elements.isNotEmpty) { + final arbitraryElement = declaredDependenciesNode.elements.first; + useNewlines = + !isSameLine(result.lineInfo, arbitraryElement.offset, declaredDependenciesNode.leftBracket.offset); + useTrailingComma = declaredDependenciesNode.rightBracket.previous?.type == TokenType.COMMA; + listElementIndent = getIndent(result.content!, result.lineInfo, arbitraryElement.offset); + } else { + useNewlines = false; + useTrailingComma = false; + listElementIndent = ''; + } + + /// Returns the [indent] decreased by one level of indentation, if possible. + /// + /// decreaseIndent(' ' /*(4 spaces)*/) // ' ' (2 spaces) + /// decreaseIndent('') // '' + String decreaseIndent(String indent) { + // `min` with the length since we can't assume te string will always be at least two characters long. + return indent.substring(min(2, indent.length)); + } + + final newDepsSource = StringBuffer()..write('['); + if (useNewlines) newDepsSource.writeln(); + newDepsSource.write( + dependencies.map((dep) => useNewlines ? '$listElementIndent$dep' : dep).join(useNewlines ? ',\n' : ', ')); + if (useTrailingComma) newDepsSource.write(','); + if (useNewlines) newDepsSource.write('\n${decreaseIndent(listElementIndent)}'); + newDepsSource.write(']'); + + return newDepsSource.toString(); + } + + await collector.addErrorWithFix( + ExhaustiveDeps.code, + result.locationFor(declaredDependenciesNode), + errorMessageArgs: [ + "React Hook ${getSource(reactiveHook)} has " + + // To avoid a long message, show the next actionable item. + (getWarningMessage(missingDependencies, 'a', 'missing', 'include') ?? + getWarningMessage(unnecessaryDependencies, 'an', 'unnecessary', 'exclude') ?? + getWarningMessage(duplicateDependencies, 'a', 'duplicate', 'omit') ?? + '') + + (extraWarning ?? ''), + ], + fixKind: ExhaustiveDeps.fixKind, + fixMessageArgs: ["Update the dependencies list to be: [${suggestedDeps.map(formatDependency).join(', ')}]"], + computeFix: () => buildGenericFileEdit(result, (e) { + e.addSimpleReplacement( + range.node(declaredDependenciesNode), prettyDependenciesList(suggestedDeps.map(formatDependency))); + }), + ); + } +} + +class _ExhaustiveDepsVisitor extends GeneralizingAstVisitor { + final RegExp? additionalHooks; + final void Function(ReactiveHookCallbackInfo) onReactiveHookCallback; + + _ExhaustiveDepsVisitor({ + this.additionalHooks, + required this.onReactiveHookCallback, + }); + + @override + void visitInvocationExpression(InvocationExpression node) { + super.visitInvocationExpression(node); + + final callbackIndex = getReactiveHookCallbackIndex(node.function, additionalHooks: additionalHooks); + if (callbackIndex == -1) { + // Not a React Hook call that needs deps. + return; + } + + final callback = node.argumentList.arguments.elementAtOrNull(callbackIndex); + final reactiveHook = node.function; + final reactiveHookName = getNodeWithoutReactNamespace(reactiveHook).toSource(); + final declaredDependenciesNode = node.argumentList.arguments.elementAtOrNull(callbackIndex + 1); + final isEffect = RegExp(r'Effect($|[^a-z])').hasMatch(reactiveHookName); + + onReactiveHookCallback(ReactiveHookCallbackInfo( + node: node, + callback: callback, + reactiveHook: reactiveHook, + reactiveHookName: reactiveHookName, + declaredDependenciesNode: declaredDependenciesNode, + isEffect: isEffect, + )); + } +} + +class ReactiveHookCallbackInfo { + final InvocationExpression? node; + final Expression? callback; + final Expression reactiveHook; + final String reactiveHookName; + final Expression? declaredDependenciesNode; + final bool isEffect; + + ReactiveHookCallbackInfo({ + required this.node, + required this.callback, + required this.reactiveHook, + required this.reactiveHookName, + required this.declaredDependenciesNode, + required this.isEffect, + }); +} + +class _Dependency { + final bool isStable; + + final List references; + + bool isUsedSomewhereAsCascadeTarget = false; + + _Dependency({required this.isStable, required this.references}); + + @override + String toString() => prettyPrint({ + 'isStable': isStable, + 'references': references, + 'isUsedSomewhereAsCascadeTarget': isUsedSomewhereAsCascadeTarget, + }); +} + +class _RefInEffectCleanup { + final Identifier reference; + + /// [reference].staticElement, but stored as a separate non-nullable field. + final Element referenceElement; + + _RefInEffectCleanup({required this.reference, required this.referenceElement}); + + @override + String toString() => prettyPrint({ + 'reference': reference, + }); +} + +Iterable resolvedReferencesWithin(AstNode node) => + allDescendantsOfType(node).where((e) => e.staticElement != null); + +enum HookTypeWithStableMethods { stateHook, reducerHook, transitionHook } + +abstract class HookConstants { + static const stateSetMethods = {'set', 'setWithUpdater'}; + + static const stableStateMethods = {...stateSetMethods}; + static const stableReducerMethods = {'dispatch'}; + static const stableTransitionMethods = {'startTransition'}; + + /// These aren't exhaustive (they don't include hashCode, and may be missing properties added in the future), + /// and should only be used in error messages or suggestions, and not in tests for stability of members. + static const knownUnstableMembersByDepType = { + _DepType.reducerHook: {'state'}, + _DepType.stateHook: {'value'}, + _DepType.transitionHook: {'isPending'}, + }; +} + +/// Information about a stable hook method reference. +/// +/// For example, `stateHook.set`, `reducerHook.dispatch`. +class StableHookMethodInfo { + final Expression node; + + final Expression target; + final Identifier property; + final HookTypeWithStableMethods hookType; + + StableHookMethodInfo({ + required this.node, + required this.target, + required this.property, + required this.hookType, + }) : assert(target.parent == node && property.parent == node); + + bool get isStateSetterMethod => + hookType == HookTypeWithStableMethods.stateHook && HookConstants.stateSetMethods.contains(property.name); +} + +/// If [node] is an access of a stable hook method on a hook object (either a method call or a tearoff), +/// returns information about that usage. Otherwise, returns `null`. +StableHookMethodInfo? getStableHookMethodInfo(AstNode node) { + // This check is redundant with the if-else below, but allows for type promotion of `node`. + if (node is! Expression) return null; + + Expression? target; + Identifier property; + if (node is MethodInvocation && !node.isCascaded) { + target = node.target; + property = node.methodName; + } else if (node is PropertyAccess && !node.isCascaded) { + target = node.target; + property = node.propertyName; + } else if (node is PrefixedIdentifier) { + target = node.prefix; + property = node.identifier; + } else { + return null; + } + + if (target == null) return null; + + final staticType = target.staticType; + if (staticType == null) return null; + + // Check whether this reference is only used to access the stable hook property. + if (staticType.isStateHook && HookConstants.stableStateMethods.contains(property.name)) { + return StableHookMethodInfo( + node: node, target: target, property: property, hookType: HookTypeWithStableMethods.stateHook); + } + if (staticType.isReducerHook && HookConstants.stableReducerMethods.contains(property.name)) { + return StableHookMethodInfo( + node: node, target: target, property: property, hookType: HookTypeWithStableMethods.reducerHook); + } + if (staticType.isTransitionHook && HookConstants.stableTransitionMethods.contains(property.name)) { + return StableHookMethodInfo( + node: node, target: target, property: property, hookType: HookTypeWithStableMethods.transitionHook); + } + + return null; +} + +/// Returns whether [body] has a declaration (a variable or local function) with the name +/// [childName]. +/// +/// Declarations in nested functions are ignored. +/// +/// Example: +/// +/// ```dart +/// // For the code: +/// // void parent(arg) { +/// // var variable; +/// // void function() { +/// // var nestedVariable; +/// // } +/// // { +/// // var insideBlock; +/// // } +/// // } +/// _hasDeclarationWithName(parentEl, 'variable'); // true +/// _hasDeclarationWithName(parentEl, 'function'); // true +/// _hasDeclarationWithName(parentEl, 'insideBlock'); // true +/// _hasDeclarationWithName(parentEl, 'nestedVariable'); // false - nested inside another function +/// _hasDeclarationWithName(parentEl, 'arg'); // false - arguments aren't matched +/// ``` +bool _hasDeclarationWithName(FunctionBody body, String childName) { + // Can't use an ElementVisitor Here since FunctionElement doesn't have references to child declarations. + // Use a visitor so we properly handle declarations inside blocks. + var hasMatch = false; + body.visitChildren(_ChildLocalVariableOrFunctionDeclarationVisitor((_, name) { + if (name.name == childName) { + hasMatch = true; + } + })); + return hasMatch; +} + +class _ChildLocalVariableOrFunctionDeclarationVisitor extends RecursiveAstVisitor { + final void Function(Declaration, SimpleIdentifier name) onChildLocalDeclaration; + + _ChildLocalVariableOrFunctionDeclarationVisitor(this.onChildLocalDeclaration); + + @override + void visitVariableDeclaration(VariableDeclaration node) { + super.visitVariableDeclaration(node); + onChildLocalDeclaration(node, node.name); + } + + @override + void visitFunctionDeclaration(FunctionDeclaration node) { + super.visitFunctionDeclaration(node); + onChildLocalDeclaration(node, node.name); + } + + @override + void visitFunctionExpression(FunctionExpression node) { + // Don't call super so that we don't recurse into function expressions, + // allowing us to ignore their parameters and any descendant declarations. + } +} + +enum _SetStateRecommendationForm { + reducer, + inlineReducer, + updater, +} + +class _SetStateRecommendation { + final String missingDep; + final String setter; + final _SetStateRecommendationForm form; + + _SetStateRecommendation({required this.missingDep, required this.setter, required this.form}); +} + +class _Recommendations { + final List suggestedDependencies; + final Set unnecessaryDependencies; + final Set missingDependencies; + final Set duplicateDependencies; + + _Recommendations({ + required this.suggestedDependencies, + required this.unnecessaryDependencies, + required this.missingDependencies, + required this.duplicateDependencies, + }); +} + +class _DepTree { + /// True if used in code + bool isUsed; + + /// True if specified in deps + bool isSatisfiedRecursively; + + /// True if something deeper is used by code + bool isSubtreeUsed; + + // Nodes for properties + final Map children; + + _DepTree( + {required this.isUsed, + required this.isSatisfiedRecursively, + required this.isSubtreeUsed, + required this.children}); +} + +class _DeclaredDependency { + final String key; + final Expression node; + + final Element? debugEnclosingElement; + + _DeclaredDependency(this.key, this.node, {this.debugEnclosingElement}); + + @override + String toString() => prettyPrint({ + 'key': key, + 'node': node, + 'debugEnclosingElement': debugEnclosingElement?.debugString, + }); +} + +extension on Element { + String get debugString => '$runtimeType<$id, ${getDisplayString(withNullability: false)}>'; +} + +// The meat of the logic. +_Recommendations collectRecommendations({ + required Map dependencies, + required List<_DeclaredDependency> declaredDependencies, + required Iterable stableDependencies, + required Set externalDependencies, + required bool isEffect, +}) { + // Our primary data structure. + // It is a logical representation of property chains: + // "props" -> "props.foo" -> "props.foo.bar" -> "props.foo.bar.baz" + // -> "props.lol" + // -> "props.huh" -> "props.huh.okay" + // -> "props.wow" + // We'll use it to mark nodes that are *used* by the programmer, + // and the nodes that were *declared* as deps. Then we will + // traverse it to learn which deps are missing or unnecessary. + _DepTree createDepTree() { + return _DepTree( + isUsed: false, // True if used in code + isSatisfiedRecursively: false, // True if specified in deps + isSubtreeUsed: false, // True if something deeper is used by code + children: {}, // Nodes for properties + ); + } + + final depTree = createDepTree(); + + // Tree manipulation helpers. + _DepTree getOrCreateNodeByPath(_DepTree rootNode, String path) { + final keys = path.split('.'); + var node = rootNode; + for (final key in keys) { + var child = node.children[key]; + if (child == null) { + child = createDepTree(); + node.children[key] = child; + } + node = child; + } + return node; + } + + void markAllParentsByPath(_DepTree rootNode, String path, void Function(_DepTree) fn) { + final keys = path.split('.'); + var node = rootNode; + for (final key in keys) { + final child = node.children[key]; + if (child == null) { + return; + } + fn(child); + node = child; + } + } + + // Mark all required nodes first. + // Imagine exclamation marks next to each used deep property. + for (final key in dependencies.keys) { + final node = getOrCreateNodeByPath(depTree, key); + node.isUsed = true; + markAllParentsByPath(depTree, key, (parent) { + parent.isSubtreeUsed = true; + }); + } + + // Mark all satisfied nodes. + // Imagine checkmarks next to each declared dependency. + for (final _entry in declaredDependencies) { + final key = _entry.key; + final node = getOrCreateNodeByPath(depTree, key); + node.isSatisfiedRecursively = true; + } + for (final key in stableDependencies) { + final node = getOrCreateNodeByPath(depTree, key); + node.isSatisfiedRecursively = true; + } + + // Now we can learn which dependencies are missing or necessary. + final missingDependencies = {}; + final satisfyingDependencies = {}; + void scanTreeRecursively( + _DepTree node, Set missingPaths, Set satisfyingPaths, String Function(String) keyToPath) { + node.children.forEach((key, child) { + final path = keyToPath(key); + if (child.isSatisfiedRecursively) { + if (child.isSubtreeUsed) { + // Remember this dep actually satisfied something. + satisfyingPaths.add(path); + } + // It doesn't matter if there's something deeper. + // It would be transitively satisfied since we assume immutability. + // "props.foo" is enough if you read "props.foo.id". + return; + } + if (child.isUsed) { + // Remember that no declared deps satisfied this node. + missingPaths.add(path); + // If we got here, nothing in its subtree was satisfied. + // No need to search further. + return; + } + scanTreeRecursively( + child, + missingPaths, + satisfyingPaths, + (childKey) => path + '.' + childKey, + ); + }); + } + + scanTreeRecursively( + depTree, + missingDependencies, + satisfyingDependencies, + (key) => key, + ); + + // Collect suggestions in the order they were originally specified. + final suggestedDependencies = []; + final unnecessaryDependencies = {}; + final duplicateDependencies = {}; + for (final _entry in declaredDependencies) { + final key = _entry.key; + // Does this declared dep satisfy a real need? + if (satisfyingDependencies.contains(key)) { + if (!suggestedDependencies.contains(key)) { + // Good one. + suggestedDependencies.add(key); + } else { + // Duplicate. + duplicateDependencies.add(key); + } + } else { + if (isEffect && !key.endsWith('.current') && !externalDependencies.contains(key)) { + // Effects are allowed extra "unnecessary" deps. + // Such as resetting scroll when ID changes. + // Consider them legit. + // The exception is ref.current which is always wrong. + if (!suggestedDependencies.contains(key)) { + suggestedDependencies.add(key); + } + } else { + // It's definitely not needed. + unnecessaryDependencies.add(key); + } + } + } + + // Then add the missing ones at the end. + suggestedDependencies.addAll(missingDependencies); + + return _Recommendations( + suggestedDependencies: suggestedDependencies, + unnecessaryDependencies: unnecessaryDependencies, + duplicateDependencies: duplicateDependencies, + missingDependencies: missingDependencies, + ); +} + +// If the node will result in constructing a referentially unique value, return +// its human readable type name, else return null. +String? getConstructionExpressionType(Expression node) { + if (node is InstanceCreationExpression) { + if (node.isConst) return null; + return node.constructorName.type.name.name; + } else if (node is ListLiteral) { + return _DepType.list; + } else if (node is SetOrMapLiteral) { + return node.isMap ? _DepType.map : _DepType.set; + } else if (node is FunctionExpression) { + return _DepType.function; + } else if (node is ConditionalExpression) { + if (getConstructionExpressionType(node.thenExpression) != null || + getConstructionExpressionType(node.elseExpression) != null) { + return _DepType.conditional; + } + return null; + } else if (node is BinaryExpression) { + if (getConstructionExpressionType(node.leftOperand) != null || + getConstructionExpressionType(node.rightOperand) != null) { + return _DepType.binaryExpression; + } + return null; + } else if (node is AssignmentExpression) { + if (getConstructionExpressionType(node.rightHandSide) != null) { + return _DepType.assignmentExpression; + } + return null; + } else if (node is AsExpression) { + return getConstructionExpressionType(node.expression); + // We can't just check for InvocationExpression with a staticType of ReactElement, since that gives false positives + // for certain stable functions that return the same ReactElement (e.g., useMemo, render props). + // Look for a component usage instead. + } else if (node is InvocationExpression && getComponentUsage(node) != null) { + return _DepType.reactElement; + } else if (node.staticType?.isStateHook ?? false) { + return _DepType.stateHook; + } else if (node.staticType?.isReducerHook ?? false) { + return _DepType.reducerHook; + } else if (node.staticType?.isTransitionHook ?? false) { + return _DepType.transitionHook; + } else { + return null; + } +} + +// Finds variables declared as dependencies +// that would invalidate on every render. +List<_Construction> scanForConstructions({ + required Iterable<_DeclaredDependency> declaredDependencies, + required AstNode declaredDependenciesNode, + required AstNode callbackNode, +}) { + bool isUsedOutsideOfHook(Declaration declaration, Element declarationElement) { + for (final reference in allResolvedReferencesTo(declarationElement, declaration.root)) { + final parent = reference.parent; + // TODO(greg) make sure there aren't edge cases here + if (parent is Declaration && parent.declaredElement == declarationElement) continue; + if (!callbackNode.containsRangeOf(reference)) { + // This reference is outside the Hook callback. + // It can only be legit if it's the deps array. + if (!declaredDependenciesNode.containsRangeOf(reference)) { + return true; + } + } + } + return false; + } + + return declaredDependencies + .map<_Construction?>((dep) { + final declarationElement = dep.node.tryCast()?.staticElement; + if (declarationElement == null) return null; + + final declaration = lookUpDeclaration(declarationElement, dep.node.root); + if (declaration == null) return null; + + // final handleChange = () {}; + // final foo = {}; + // final foo = []; + // etc. + if (declaration is VariableDeclaration) { + // Const variables never change + if (declaration.isConst) return null; + final initializer = declaration.initializer; + if (initializer != null) { + final constantExpressionType = getConstructionExpressionType( + initializer, + ); + if (constantExpressionType != null) { + return _Construction( + declaredDependency: dep, + declaration: declaration, + declarationElement: declarationElement, + depType: constantExpressionType, + isUsedOutsideOfHook: isUsedOutsideOfHook(declaration, declarationElement), + ); + } + } + return null; + } + // handleChange() {} + if (declaration is FunctionDeclaration) { + return _Construction( + declaredDependency: dep, + declaration: declaration, + declarationElement: declarationElement, + depType: _DepType.function, + isUsedOutsideOfHook: isUsedOutsideOfHook(declaration, declarationElement), + ); + } + + return null; + }) + .whereNotNull() + .toList(); +} + +class _Construction { + final _DeclaredDependency declaredDependency; + final Declaration declaration; + final Element declarationElement; + + /// Will either be a constant in [_DepType] or the name of the object/constructor being used. + final String depType; + final bool isUsedOutsideOfHook; + + _Construction({ + required this.declaredDependency, + required this.declaration, + required this.declarationElement, + required this.depType, + required this.isUsedOutsideOfHook, + }); + + @override + String toString() => prettyPrint({ + 'declaredDependency': declaredDependency, + 'declaration': declaration, + 'depType': depType, + 'isUsedOutsideOfHook': isUsedOutsideOfHook, + }); +} + +abstract class _DepType { + static const list = 'List'; + static const map = 'Map'; + static const set = 'Set'; + static const function = 'function'; + static const conditional = 'conditional'; + static const binaryExpression = 'binary expression'; + static const assignmentExpression = 'assignment expression'; + static const reactElement = 'ReactElement'; + static const reducerHook = 'ReducerHook (from useReducer)'; + static const stateHook = 'StateHook (from useState)'; + static const transitionHook = 'TransitionHook (from useTransition)'; + static const hookObjects = {reducerHook, stateHook, transitionHook}; +} + +/// Returns whether a property invocation should be treated as a separate dependency from its parent, +/// which is the default behavior. For example, the dependency of `props.foo` should be `props`. +/// +/// Returns true for stable hook calls and calls to function props. +/// +/// For example, the dependency for `props.someFunction()` should be `props.someFunction` instead of `props, +/// and the dependency for `stateHook.set(null)` should be `stateHook.set` and not `stateHook`. +bool isInvocationADiscreteDependency(PropertyInvocation invocation) { + bool isProps(Expression e) => e is Identifier && e.name == 'props'; + + final target = invocation.target; + assert(target != null, + 'target should never be null; callers should check that first, since cascaded calls should not be treated as dependencies'); + if (target == null) return false; + + return isProps(target) || getStableHookMethodInfo(invocation.functionName.parent!) != null; +} + +/// Assuming () means the passed/returned node: +/// (props) => (props) +/// props.(foo) => (props.foo) +/// props.foo.(bar) => (props).foo.bar +/// props.foo.bar.(baz) => (props).foo.bar.baz +Expression getDependency(Expression node) { + final parent = node.parent!; + final grandparent = parent.parent; + + // Invocations can show up as MethodInvocations or an FunctionExpressionInvocation with a PropertyAccess. + // We want to handle both here. + // This invocation check deviates from the JS, and is necessary to handle stable hook methods and function props. + final propertyInvocation = PropertyInvocation.detectClosest(node); + if (propertyInvocation != null && propertyInvocation.target == node) { + if (isInvocationADiscreteDependency(propertyInvocation)) { + // Return this directly since we don't want to recurse into property accesses on the return value. + return propertyInvocation.invocation; + } + } + + if (parent is PropertyAccess && + parent.target == node && + parent.propertyName.name != 'current' && // TODO(greg) only test for ref/dynamic values? + !(grandparent is InvocationExpression && grandparent.function == parent)) { + return getDependency(parent); + } + if (parent is PrefixedIdentifier && + parent.prefix == node && + parent.identifier.name != 'current' && // TODO(greg) only test for ref/dynamic values? + !(grandparent is InvocationExpression && grandparent.function == parent)) { + return getDependency(parent); + } + + if (parent is AssignmentExpression && parent.leftHandSide == node) { + if (node is PropertyAccess) return node.realTarget; + if (node is PrefixedIdentifier) return node.prefix; + } + + return node; +} + +/// Mark a node as either optional or required. +/// Note: If the node argument is an OptionalMemberExpression, it doesn't necessarily mean it is optional. +/// It just means there is an optional member somewhere inside. +/// This particular node might still represent a required member, so check .optional field. +void markNode(AstNode node, Map? optionalChains, String result, {required bool? isOptional}) { + if (optionalChains != null && isOptional == null) { + throw ArgumentError('isOptional must be specified when optionalChains is'); + } + + if (optionalChains != null) { + if (isOptional!) { + // We only want to consider it optional if *all* usages were optional. + if (!optionalChains.containsKey(result)) { + // Mark as (maybe) optional. If there's a required usage, this will be overridden. + optionalChains[result] = true; + } + } else { + // Mark as required. + optionalChains[result] = false; + } + } +} + +/// Assuming () means the passed node. +/// (foo) -> 'foo' +/// foo(.)bar -> 'foo.bar' +/// foo.bar(.)baz -> 'foo.bar.baz' +/// Otherwise throw. +/// +/// If [isInvocationAllowedForNode] is true, then invocations will be handled (for certain cases, +/// like function props and stable hook methods). Otherwise, this will throw. +String analyzePropertyChain(AstNode node, Map? optionalChains, + {required bool isInvocationAllowedForNode}) { + late final propertyInvocation = PropertyInvocation.detect(node); + + if (node is SimpleIdentifier) { + final result = node.name; + if (optionalChains != null) { + // Mark as required. + optionalChains[result] = false; + } + return result; + } else if (node is PropertyAccess) { + assert(!node.isCascaded, 'cascaded members are unexpected here'); + final object = analyzePropertyChain(node.target!, optionalChains, isInvocationAllowedForNode: false); + final property = analyzePropertyChain(node.propertyName, null, isInvocationAllowedForNode: false); + final result = "$object.$property"; + markNode(node, optionalChains, result, isOptional: node.isNullAware); + return result; + } else if (node is PrefixedIdentifier) { + final object = analyzePropertyChain(node.prefix, optionalChains, isInvocationAllowedForNode: false); + final property = analyzePropertyChain(node.identifier, null, isInvocationAllowedForNode: false); + final result = "$object.$property"; + markNode(node, optionalChains, result, isOptional: false); + return result; + } else if (isInvocationAllowedForNode && + propertyInvocation != null && + // Rule out cascades and implicit this + propertyInvocation.target != null && + isInvocationADiscreteDependency(propertyInvocation)) { + // This invocation check deviates from the JS, and is necessary to handle stable hook methods and function props. + final object = analyzePropertyChain(propertyInvocation.target!, optionalChains, isInvocationAllowedForNode: false); + final property = analyzePropertyChain(propertyInvocation.functionName, null, isInvocationAllowedForNode: false); + final result = "$object.$property"; + markNode(node, optionalChains, result, isOptional: propertyInvocation.isNullAware); + return result; + } else { + throw UnsupportedNodeTypeException(node); + } +} + +class UnsupportedNodeTypeException implements Exception { + AstNode node; + + UnsupportedNodeTypeException(this.node); + + @override + String toString() => '$runtimeType: ${node.runtimeType} (source: ${node.toSource()})'; +} + +AstNode getNodeWithoutReactNamespace(Expression node) { + bool isPrefixedOverReactApi({required Identifier prefix, required Identifier identifier}) { + // Assume unresolved calls are from over_react. + return (prefix.staticElement == null || prefix.staticElement is PrefixElement) && + (identifier.staticElement?.isDeclaredInPackage('over_react') ?? true); + } + + if (node is PrefixedIdentifier) { + if (isPrefixedOverReactApi(prefix: node.prefix, identifier: node.identifier)) { + return node.identifier; + } + } else if (node is PropertyAccess) { + final realTarget = node.realTarget; + if (realTarget is Identifier && isPrefixedOverReactApi(prefix: realTarget, identifier: node.propertyName)) { + return node.propertyName; + } + } + return node; +} + +// What's the index of callback that needs to be analyzed for a given Hook? +// -1 if it's not a Hook we care about (e.g. useState). +// 0 for useEffect/useMemo/useCallback(fn). +// 1 for useImperativeHandle(ref, fn). +// For additionally configured Hooks, assume that they're like useEffect (0). +int getReactiveHookCallbackIndex(Expression calleeNode, {RegExp? additionalHooks}) { + calleeNode = calleeNode.unParenthesized; + final node = getNodeWithoutReactNamespace(calleeNode); + if (node is! Identifier) { + return -1; + } + switch (node.name) { + case 'useEffect': + case 'useLayoutEffect': + case 'useCallback': + case 'useMemo': + // useEffect(fn) + return 0; + case 'useImperativeHandle': + // useImperativeHandle(ref, fn) + return 1; + default: + // TODO(greg) should we strip prefix before passing it into additionalHooks? + if (node == calleeNode && additionalHooks != null) { + // Allow the user to provide a regular expression which enables the lint to + // target custom reactive hooks. + String name; + try { + name = analyzePropertyChain(node, null, isInvocationAllowedForNode: false); + } on UnsupportedNodeTypeException catch (_) { + return 0; + } + return additionalHooks.hasMatch(name) ? 0 : -1; + } else { + return -1; + } + } +} + +String joinEnglish(Iterable items, [String joinWord = 'and']) { + final arr = items is List ? items : items.toList(); + var s = ''; + for (var i = 0; i < arr.length; i++) { + s += arr[i]; + if (i == 0 && arr.length == 2) { + s += ' $joinWord '; + } else if (i == arr.length - 2 && arr.length > 2) { + s += ', $joinWord '; + } else if (i < arr.length - 1) { + s += ', '; + } + } + return s; +} + +extension> on Iterable { + /// Whether the elements in this collection are already sorted. + bool get isSorted { + var isFirst = true; + late E prev; + for (final element in this) { + if (isFirst) { + isFirst = false; + } else if (prev.compareTo(element) > 0) { + return false; + } + prev = element; + } + return true; + } +} diff --git a/tools/analyzer_plugin/lib/src/diagnostic/hooks_exhaustive_deps.dart b/tools/analyzer_plugin/lib/src/diagnostic/hooks_exhaustive_deps.dart deleted file mode 100644 index e3e861a40..000000000 --- a/tools/analyzer_plugin/lib/src/diagnostic/hooks_exhaustive_deps.dart +++ /dev/null @@ -1,1925 +0,0 @@ -//@dart=2.9 -// Adapted from https://github.com/facebook/react/blob/main@%7B2020-10-16%7D/packages/eslint-plugin-react-hooks/src/ExhaustiveDeps.js -// -// MIT License -// -// Copyright (c) Facebook, Inc. and its affiliates. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -import 'package:analyzer/dart/analysis/results.dart'; -import 'package:analyzer/dart/ast/ast.dart'; -import 'package:analyzer/dart/ast/syntactic_entity.dart'; -import 'package:analyzer/dart/ast/visitor.dart'; -import 'package:analyzer/dart/element/element.dart'; -import 'package:analyzer_plugin/protocol/protocol_common.dart' show Location; -import 'package:meta/meta.dart'; -import 'package:over_react_analyzer_plugin/src/diagnostic/analyzer_debug_helper.dart'; -import 'package:over_react_analyzer_plugin/src/diagnostic_contributor.dart'; -import 'package:over_react_analyzer_plugin/src/util/analyzer_util.dart'; -import 'package:over_react_analyzer_plugin/src/util/ast_util.dart'; -import 'package:over_react_analyzer_plugin/src/util/util.dart'; -import 'package:over_react_analyzer_plugin/src/util/react_types.dart'; - -// API reference for JS reference/scope APIs: -// https://eslint.org/docs/developer-guide/scope-manager-interface - -// -// final _hookNamePattern = RegExp(r'^use[A-Z0-9].*$'); -// -// /// Catch all identifiers that begin with "use" followed by an uppercase Latin -// /// character to exclude identifiers like "user". -// bool isHookName(String s) => _hookNamePattern.hasMatch(s); -// - -class HooksExhaustiveDeps extends DiagnosticContributor { - static const debugEnabled = false; - - @DocsMeta('Verifies the list of dependencies for React Hooks like useEffect and similar', details: '') - static const code = DiagnosticCode( - 'over_react_hooks_exhaustive_deps', - "{0}", - AnalysisErrorSeverity.ERROR, - AnalysisErrorType.STATIC_WARNING, - url: 'https://reactjs.org/docs/hooks-rules.html', - ); - - @override - List get codes => [code]; - - static final fixKind = FixKind(code.name, 200, "{0}"); - - @override - Future computeErrors(result, collector) async { - final helper = AnalyzerDebugHelper(result, collector); - result.unit.accept(_ExhaustiveDepsVisitor( - result: result, - diagnosticCollector: collector, - getSource: (node) => result.content.substring(node.offset, node.end), - debug: (string, location) { - if (!debugEnabled) return; - Location _location; - if (location is Location) { - _location = location; - } else if (location is AstNode) { - _location = result.locationFor(location); - } else if (location is int) { - _location = result.location(offset: location); - } else { - throw ArgumentError.value(location, 'location'); - } - helper.logWithLocation(string, _location); - }, - )); - } -} - -class WeakSet { - final _isEntry = Expando(); - - void add(E key) { - _isEntry[key] = const Object(); - } - - bool has(E key) { - if (key == null) return false; - return _isEntry[key] != null; - } - - void remove(E key) { - _isEntry[key] = null; - } -} - -class WeakMap { - final _keys = WeakSet(); - final _valueFor = Expando(); - - V get(K key) => has(key) ? _valueFor[key] : null; - - void set(K key, V value) { - _keys.add(key); - _valueFor[key] = value; - } - - bool has(K key) => _keys.has(key); - - void remove(K key) { - _keys.remove(key); - _valueFor[key] = null; - } - - V putIfAbsent(K key, V Function() ifAbsent) { - if (has(key)) return get(key); - final value = ifAbsent(); - set(key, value); - return value; - } -} - -extension on V Function(K) { - V Function(K) memoizeWithWeakMap(WeakMap map) { - return (key) => map.putIfAbsent(key, () => this(key)); - } -} - -class _Dependency { - final bool isStable; - final List references; - - _Dependency({@required this.isStable, @required this.references}); - - @override - String toString() => prettyString({ - 'isStable': isStable, - 'references': references, - }); -} - -class _RefInEffectCleanup { - final Identifier reference; - final Identifier dependencyNode; - - _RefInEffectCleanup({this.reference, this.dependencyNode}); - - @override - String toString() => prettyString({ - 'reference': reference, - 'dependencyNode': dependencyNode, - }); -} - -FunctionExpression lookUpFunction(Element element, AstNode root) { - final node = NodeLocator2(element.nameOffset).searchWithin(root); - if (node is Identifier && node.staticElement == element) { - final parent = node.parent; - return parent.tryCast()?.functionExpression ?? - parent.tryCast()?.initializer?.tryCast(); - } - - return null; -} - -Declaration lookUpDeclaration(Element element, AstNode root) { - // if (element is ExecutableElement) return null; - final node = NodeLocator2(element.nameOffset).searchWithin(root); - final declaration = node?.thisOrAncestorOfType(); - if (declaration?.declaredElement == element) { - return declaration; - } - - return null; -} - -Iterable resolvedReferencesWithin(AstNode node) => - allDescendantsOfType(node).where((e) => e.staticElement != null); - -class _ExhaustiveDepsVisitor extends GeneralizingAstVisitor { - // Should be shared between visitors. - /// A mapping from setState references to setState declarations - final setStateCallSites = WeakMap(); - final stateVariables = WeakSet(); - final stableKnownValueCache = WeakMap(); - final functionWithoutCapturedValueCache = WeakMap(); - - DiagnosticCollector diagnosticCollector; - - void reportProblem({@required AstNode node, String message}) { - diagnosticCollector.addError(HooksExhaustiveDeps.code, result.locationFor(node), errorMessageArgs: [ - message ?? '', - ]); - } - - final ResolvedUnitResult result; - final String Function(SyntacticEntity entity) getSource; - final RegExp additionalHooks; - final void Function(String string, Object location) debug; - - _ExhaustiveDepsVisitor({ - @required this.diagnosticCollector, - @required this.getSource, - @required this.result, - @required this.debug, - this.additionalHooks, - }); - -// Visitor for both function expressions and arrow function expressions. - void visitFunctionWithDependencies({ - @required FunctionBody node, - @required AstNode declaredDependenciesNode, - @required AstNode reactiveHook, - @required String reactiveHookName, - @required bool isEffect, - }) { - final rootNode = node.root; - - if (isEffect && node.isAsynchronous) { - reportProblem( - node: node, - message: "Effect callbacks are synchronous to prevent race conditions. " - "Put the async inside:\n\n" - 'useEffect(() {\n' - ' fetchData() async {\n' - ' // You can await here\n' - ' final response = await myAPI.getData(someId);\n' - ' // ...\n' - ' }\n' - ' fetchData();\n' - "}, [someId]); // Or [] if effect doesn't need props or state\n\n" - 'Learn more about data fetching with Hooks: https://reactjs.org/link/hooks-data-fetching', - ); - } - - // Find all our "pure scopes". On every re-render of a component these - // pure scopes may have changes to the variables declared within. So all - // variables used in our reactive hook callback but declared in a pure - // scope need to be listed as dependencies of our reactive hook callback. - // - // According to the rules of React you can't read a mutable value in pure - // scope. We can't enforce this in a lint so we trust that all variables - // declared outside of pure scope are indeed frozen. - - // Pure scopes include all scopes from the parent scope of the callback - // to the first function scope (which will be either the component/render function or a custom hook, - // since hooks can only be called from the top level). - - // todo improve this - final componentFunction = node.ancestors.whereType().last; - assert(componentFunction != null); - assert(componentFunction != node.thisOrAncestorOfType()); - - final componentFunctionElement = componentFunction.declaredElement; - - debug('componentFunctionElement: ' + componentFunctionElement.debugString, componentFunction.offset); - - bool isDeclaredInPureScope(Element element) => - element.thisOrAncestorOfType() == componentFunctionElement; - - // uiFunction((props), { - // // Pure scope 2 - // var renderVar; - // { - // // Pure scope 1 - // var blockVar; - // - // useEffect(() { - // // This effect callback block is , the function we're visiting - // }); - // } - // }, ...) - - // Next we'll define a few helpers that helps us - // tell if some values don't have to be declared as deps. - - // Some are known to be stable based on Hook calls. - // const [state, setState] = useState() / React.useState() - // ^^^ true for this reference - // const [state, dispatch] = useReducer() / React.useReducer() - // ^^^ true for this reference - // const ref = useRef() - // ^^^ true for this reference - // False for everything else. - bool isStableKnownHookValue(Identifier reference) { - // FIXME what about function declarations? are those handled elsewhere - final declaration = lookUpDeclaration(reference.staticElement, reference.root)?.tryCast(); - var init = declaration?.initializer; - if (init == null) { - return false; - } - - Expression unwrap(Expression expr) { - if (expr is AsExpression) return expr.expression; - if (expr is ParenthesizedExpression) return expr.expression; - return expr; - } - - init = unwrap(init); - - // Detect primitive constants - // const foo = 42 - - // Note: in the JS version of this plugin, there was: - // > This might happen if variable is declared after the callback. - // but that isn't possible in Dart, so we can omit that logic. - - if (declaration.isConst || (declaration.isFinal && !declaration.isLate && isAConstantValue(init))) { - // Definitely stable - return true; - } - - // Detect known Hook calls - // const [_, setState] = useState() - - // todo support useTransition - const stableStateHookMethods = {'set', 'setWithUpdater'}; - const stableReducerHookMethods = {'dispatch'}; - - // Handle hook tearoffs - // final setCount = useCount(1).set; - if (init is PropertyAccess) { - final property = init.propertyName.name; - if (stableStateHookMethods.contains(property) && (init.staticType?.element?.isStateHook ?? false)) { - setStateCallSites.set(reference, declaration); - return true; - } - if (stableReducerHookMethods.contains(property) && (init.staticType?.element?.isReducerHook ?? false)) { - return true; - } - } - - SimpleIdentifier propertyBeingAccessed() => - propertyNameFromNonCascadedAccessOrInvocation(reference.parent.tryCast()); - - if (reference.staticType?.element?.isStateHook ?? false) { - // Check whether this reference is only used to access the stable hook property. - final property = propertyBeingAccessed(); - if (property != null && stableStateHookMethods.contains(property.name)) { - setStateCallSites.set(reference, declaration); - return true; - } - return false; - } - if (reference.staticType?.element?.isReducerHook ?? false) { - // Check whether this reference is only used to access the stable hook property. - final property = propertyBeingAccessed(); - if (property != null && stableReducerHookMethods.contains(property.name)) { - return true; - } - return false; - } - - if (init is! InvocationExpression) { - return false; - } - - // TODO do we need to check for direct invocations for other cases if typing is available? - - var callee = (init as InvocationExpression).function; - // fixme handle namespaced imports - if (callee is! Identifier) { - return false; - } - final name = (callee as Identifier).name; - if (name == 'useRef') { - // useRef() return value is stable. - return true; - } - - // By default assume it's dynamic. - return false; - } - - // Remember such values. Avoid re-running extra checks on them. - final memoizedIsStableKnownHookValue = isStableKnownHookValue.memoizeWithWeakMap(stableKnownValueCache); - - // Some are just functions that don't reference anything dynamic. - bool isFunctionWithoutCapturedValues(Element resolved) { - final fnNode = lookUpFunction(resolved, rootNode); - - // It's couldn't be looked up, it's either: - // - not a function, and we need to return false - // - a function expression // todo return true for this case? - // - not in the same file // todo return true for this case? - if (fnNode == null) return false; - - // If it's outside the component, it also can't capture values. - if (!componentFunction.containsRangeOf(fnNode)) return true; - - // Does this function capture any values - // that are in pure scopes (aka render)? - final referencedElements = resolvedReferencesWithin(fnNode.body); - for (final ref in referencedElements) { - if (isDeclaredInPureScope(ref.staticElement) && - // Stable values are fine though, - // although we won't check functions deeper. - !memoizedIsStableKnownHookValue(ref)) { - return false; - } - } - // If we got here, this function doesn't capture anything - // from render--or everything it captures is known stable. - return true; - } - - final memoizedIsFunctionWithoutCapturedValues = - isFunctionWithoutCapturedValues.memoizeWithWeakMap(functionWithoutCapturedValueCache); - - // These are usually mistaken. Collect them. - final currentRefsInEffectCleanup = {}; - - // Is this reference inside a cleanup function for this effect node? - // We can check by traversing scopes upwards from the reference, and checking - // if the last "return () => " we encounter is located directly inside the effect. - bool isInsideEffectCleanup(AstNode reference) { - var isInReturnedFunction = false; - reference.ancestors.whereType().takeWhile((ancestor) => ancestor != node).forEach((current) { - // TODO why doesn't the original source just check the last one? - isInReturnedFunction = current?.parentOfType()?.parentOfType() != null; - }); - return isInReturnedFunction; - } - - // Get dependencies from all our resolved references in pure scopes. - // Key is dependency string, value is whether it's stable. - final dependencies = {}; - final optionalChains = {}; - - for (final reference in resolvedReferencesWithin(node)) { - // debug( - // 'reference.staticElement.ancestors: \n${prettyString(reference.staticElement.ancestors.map(elementDebugString).toList())}', - // reference); - - // If this reference is not resolved or it is not declared in a pure - // scope then we don't care about this reference. - - // TODO follow up on this and see how dynamic calls are treated - if (reference.staticElement == null) continue; - - if (!isDeclaredInPureScope(reference.staticElement)) { - continue; - } - - // Narrow the scope of a dependency if it is, say, a member expression. - // Then normalize the narrowed dependency. - final dependencyNode = getDependency(reference); - final dependency = analyzePropertyChain( - dependencyNode, - optionalChains, - ); - debug( - 'dependency: $dependency, dependencyNode: ${dependencyNode.runtimeType} $dependencyNode, reference ${reference.runtimeType} $reference', - dependencyNode); - - // Accessing ref.current inside effect cleanup is bad. - if ( - // We're in an effect... - isEffect && - // ... and this look like accessing .current... - dependencyNode is Identifier && - dependencyNode.parent.tryCast()?.propertyName?.name == 'current' && - // ...in a cleanup function or below... - isInsideEffectCleanup(reference)) { - currentRefsInEffectCleanup[dependency] = _RefInEffectCleanup( - reference: reference, - dependencyNode: dependencyNode, - ); - } - - // FIXME need to check more parents for GenericFunctionType case? - if (node.parent is NamedType) { - continue; - } - - // FIXME add tests to ensure references to type parameters don't make it this far. - - // Add the dependency to a map so we can make sure it is referenced - // again in our dependencies array. Remember whether it's stable. - if (!dependencies.containsKey(dependency)) { - final isStable = memoizedIsStableKnownHookValue(reference) || - // FIXME handle .call tearoffs - memoizedIsFunctionWithoutCapturedValues(reference.staticElement); - dependencies[dependency] = _Dependency( - isStable: isStable, - references: [reference], - ); - } else { - dependencies[dependency].references.add(reference); - } - } - - // Warn about accessing .current in cleanup effects. - currentRefsInEffectCleanup.forEach( - (dependency, _entry) { - final reference = _entry.reference; - final dependencyNode = _entry.dependencyNode; - - // Is React managing this ref or us? - // Let's see if we can find a .current assignment. - var foundCurrentAssignment = false; - // TODO find root for reference element, which may be in a different AST than the reference - for (final reference in findReferences(reference.staticElement, reference.root)) { - final parent = reference.parent; - if ( - // ref.current - parent?.tryCast()?.propertyName?.name == 'current' && - // ref.current = - parent.parent?.tryCast()?.leftHandSide == parent) { - foundCurrentAssignment = true; - break; - } - } - // We only want to warn about React-managed refs. - if (foundCurrentAssignment) { - return; - } - reportProblem( - node: dependencyNode.parent, - message: "The ref value '$dependency.current' will likely have " - "changed by the time this effect cleanup runs. If " - "this ref points to a node rendered by React, copy " - "'$dependency.current' to a variable inside the effect, and " - "use that variable in the cleanup function.", - ); - }, - ); - - // Warn about assigning to variables in the outer scope. - // Those are usually bugs. - final staleAssignments = {}; - void reportStaleAssignment(Expression writeExpr, String key) { - if (staleAssignments.contains(key)) { - return; - } - staleAssignments.add(key); - reportProblem( - node: writeExpr, - message: "Assignments to the '$key' variable from inside React Hook " - "${getSource(reactiveHook)} will be lost after each " - "render. To preserve the value over time, store it in a useRef " - "Hook and keep the mutable value in the '.current' property. " - "Otherwise, you can move this variable directly inside " - "${getSource(reactiveHook)}.", - ); - } - - // Remember which deps are stable and report bad usage first. - final stableDependencies = {}; - dependencies.forEach((key, dep) { - if (dep.isStable) { - stableDependencies.add(key); - } - for (final reference in dep.references) { - final parent = reference.parent; - // todo make a utility to check for assignments - if (parent is AssignmentExpression && parent.leftHandSide == reference) { - reportStaleAssignment(parent, key); - } - } - }); - - if (staleAssignments.isNotEmpty) { - // The intent isn't clear so we'll wait until you fix those first. - return; - } - - if (declaredDependenciesNode == null) { - // Check if there are any top-level setState() calls. - // Those tend to lead to infinite loops. - String setStateInsideEffectWithoutDeps; - dependencies.forEach((key, _entry) { - final references = _entry.references; - if (setStateInsideEffectWithoutDeps != null) { - return; - } - references.forEach((reference) { - if (setStateInsideEffectWithoutDeps != null) { - return; - } - - // FIXME should this use Dart element model to check this as opposed to WeakMap? - final isSetState = setStateCallSites.has(reference); - if (!isSetState) { - return; - } - - final isDirectlyInsideEffect = reference.thisOrAncestorOfType() == node; - if (isDirectlyInsideEffect) { - // TODO: we could potentially ignore early returns. - setStateInsideEffectWithoutDeps = key; - } - }); - }); - if (setStateInsideEffectWithoutDeps != null) { - final suggestedDependencies = collectRecommendations( - dependencies: dependencies, - declaredDependencies: [], - stableDependencies: stableDependencies, - externalDependencies: {}, - isEffect: true, - ).suggestedDependencies; - reportProblem( - node: reactiveHook, - message: "React Hook $reactiveHookName contains a call to '$setStateInsideEffectWithoutDeps'. " - "Without a list of dependencies, this can lead to an infinite chain of updates. " - "To fix this, pass [" - "${suggestedDependencies.join(', ')}" - "] as a second argument to the $reactiveHookName Hook.", - // suggest: [ - // { - // desc: "Add dependencies array: [${suggestedDependencies.join( - // ', ', - // )}]", - // fix(fixer) { - // return fixer.insertTextAfter( - // node, - // ", [${suggestedDependencies.join(', ')}]", - // ); - // }, - // }, - // ], - ); - } - return; - } - - final declaredDependencies = <_DeclaredDependency>[]; - final externalDependencies = {}; - if (declaredDependenciesNode is! ListLiteral) { - // If the declared dependencies are not an array expression then we - // can't verify that the user provided the correct dependencies. Tell - // the user this in an error. - reportProblem( - node: declaredDependenciesNode, - message: "React Hook ${getSource(reactiveHook)} was passed a " - 'dependency list that is not a list literal. This means we ' - "can't statically verify whether you've passed the correct " - 'dependencies.', - ); - } else { - for (final _declaredDependencyNode in (declaredDependenciesNode as ListLiteral).elements) { - // Skip elided elements. - if (_declaredDependencyNode == null) { - continue; - } - - String invalidType; - if (_declaredDependencyNode is SpreadElement) { - invalidType = 'a spread element'; - } else if (_declaredDependencyNode is IfElement) { - invalidType = "an 'if' element"; - } else if (_declaredDependencyNode is ForElement) { - invalidType = "a 'for' element"; - } else if (_declaredDependencyNode is! Expression) { - // This should be unreachable at the time of writing, - // since all other CollectionElement subtypes are handled - invalidType = "a non-expression (${_declaredDependencyNode.runtimeType})"; - } - if (invalidType != null) { - reportProblem( - node: _declaredDependencyNode, - message: "React Hook ${getSource(reactiveHook)} has $invalidType" - "in its dependency list. This means we can't " - "statically verify whether you've passed the " - 'correct dependencies.', - ); - continue; - } - - // new variable since breaks don't have type promition yet. TODO switch to the following when nnbd is enabled - // if (declaredDependencyNode is! Expression) { ... continue;} - final declaredDependencyNode = _declaredDependencyNode as Expression; - - // FIXME check here or somewhere else to ensure whole hook (state, ref?) isn't passed in, provide quick fix; perhaps non-destructured hooks being passed in are accounted for alredy? - - if (isAConstantValue(declaredDependencyNode)) { - reportProblem( - node: declaredDependencyNode, - message: "The '${declaredDependencyNode.toSource()}' constant expression is not a valid dependency " - "because it never changes. ", - ); - continue; - } - - // Special case for Dart: whole state hook passed in as dependency. - if (declaredDependencyNode?.staticType?.element?.isStateHook ?? false) { - final dependencySource = getSource(declaredDependencyNode); - final dependencySourceValue = '$dependencySource.value'; - // fixme(greg) conditionally suggest value or removing the dep based on whether count.value is used in hook? Also figure out why `useEffect(() => print(count.value), [count]);` triggers missing dependency case - - // FIXME(greg) this is async :/ - diagnosticCollector.addErrorWithFix( - HooksExhaustiveDeps.code, - result.locationFor(declaredDependencyNode), - // todo(greg) better error and fix message - errorMessageArgs: [ - "React Hook ${getSource(reactiveHook)} has a StateHook object '$dependencySource' in its dependency list, which will change every render and cause the effect to always run." - ], - fixKind: HooksExhaustiveDeps.fixKind, - fixMessageArgs: ["Depend on '$dependencySourceValue' instead."], - computeFix: () => buildGenericFileEdit(result, (builder) { - builder.addSimpleReplacement(range.node(declaredDependencyNode), dependencySourceValue); - }), - ); - continue; - } - - // Try to normalize the declared dependency. If we can't then an error - // will be thrown. We will catch that error and report an error. - String declaredDependency; - try { - declaredDependency = analyzePropertyChain( - declaredDependencyNode, - null, - ); - } catch (error) { - if (error.toString().contains('Unsupported node type')) { - // FIXME figure out what was actually going on here with .raw/.value - // if (declaredDependencyNode.type == 'Literal') { - // if (dependencies.containsKey(declaredDependencyNode.value)) { - // reportProblem( - // node: declaredDependencyNode, - // message: - // "The ${declaredDependencyNode.raw} literal is not a valid dependency " - // "because it never changes. " - // "Did you mean to include ${declaredDependencyNode.value} in the array instead?", - // ); - // } else { - // reportProblem( - // node: declaredDependencyNode, - // message: - // "The ${declaredDependencyNode.raw} literal is not a valid dependency " - // 'because it never changes. You can safely remove it.', - // ); - // } - // } else { - reportProblem( - node: declaredDependencyNode, - message: "React Hook ${getSource(reactiveHook)} has a " - "complex expression in the dependency array. " - 'Extract it to a separate variable so it can be statically checked.', - ); - // } - - continue; - } else { - rethrow; - } - } - - // todo(greg) handle / warn about cascades? - var maybeID = declaredDependencyNode; - while (maybeID is PropertyAccess) { - maybeID = (maybeID as PropertyAccess).target; - } - while (maybeID is PrefixedIdentifier) { - maybeID = (maybeID as PrefixedIdentifier).prefix; - } - final isDeclaredInComponent = - maybeID.tryCast()?.staticElement?.enclosingElement == componentFunctionElement; - - // Add the dependency to our declared dependency map. - declaredDependencies.add(_DeclaredDependency( - declaredDependency, - declaredDependencyNode, - debugEnclosingElement: maybeID.tryCast()?.staticElement?.enclosingElement, - )); - - if (!isDeclaredInComponent) { - externalDependencies.add(declaredDependency); - } - } - } - - debug( - prettyString({ - 'dependencies': dependencies, - 'declaredDependencies': declaredDependencies, - 'stableDependencies': stableDependencies, - 'externalDependencies': externalDependencies, - 'isEffect': isEffect, - }), - node.offset); - - final recommendations = collectRecommendations( - dependencies: dependencies, - declaredDependencies: declaredDependencies, - stableDependencies: stableDependencies, - externalDependencies: externalDependencies, - isEffect: isEffect, - ); - final duplicateDependencies = recommendations.duplicateDependencies; - final missingDependencies = recommendations.missingDependencies; - final unnecessaryDependencies = recommendations.unnecessaryDependencies; - - var suggestedDeps = recommendations.suggestedDependencies; - - final problemCount = duplicateDependencies.length + missingDependencies.length + unnecessaryDependencies.length; - - if (problemCount == 0) { - // If nothing else to report, check if some dependencies would - // invalidate on every render. - final constructions = scanForConstructions( - declaredDependencies: declaredDependencies, - declaredDependenciesNode: declaredDependenciesNode, - ); - constructions.forEach((_construction) { - final construction = _construction.declaration; - final constructionName = construction.declaredElement.name; - - final isUsedOutsideOfHook = _construction.isUsedOutsideOfHook; - final depType = _construction.depType; - final wrapperHook = depType == 'function' ? 'useCallback' : 'useMemo'; - - final constructionType = depType == 'function' ? 'definition' : 'initialization'; - - final defaultAdvice = "wrap the $constructionType of '$constructionName' in its own $wrapperHook() Hook."; - - final advice = isUsedOutsideOfHook - ? "To fix this, $defaultAdvice" - : "Move it inside the $reactiveHookName callback. Alternatively, $defaultAdvice"; - - final causation = depType == 'conditional' || depType == 'logical expression' ? 'could make' : 'makes'; - - final message = "The '$constructionName' $depType $causation the dependencies of " - "$reactiveHookName Hook (at line ${result.lineInfo?.getLocation(declaredDependenciesNode.offset)?.lineNumber}) " - "change on every render. $advice"; - - // Only handle the simple case of variable assignments. - // Wrapping function declarations can mess up hoisting. - if (isUsedOutsideOfHook && - construction is VariableDeclaration && - // Objects may be mutated ater construction, which would make this - // fix unsafe. Functions _probably_ won't be mutated, so we'll - // allow this fix for them. - depType == 'function') { - // FIXME(greg) is it safe to assume this here? - assert(construction.initializer != null); - - // FIXME(greg) this is async :/ - diagnosticCollector.addErrorWithFix( - HooksExhaustiveDeps.code, - result.locationFor(construction), - errorMessageArgs: [message], - fixKind: HooksExhaustiveDeps.fixKind, - fixMessageArgs: ["Wrap the $constructionType of '$constructionName' in its own $wrapperHook() Hook."], - computeFix: () => buildGenericFileEdit( - result, - (builder) { - // final parts = wrapperHook == 'useMemo' ? ['useMemo(() => ', ')'] : ['useCallback(', ')']; - // TODO: ideally we'd gather deps here but it would require - // restructuring the rule code. Note we're - // not adding [] because would that changes semantics. - - if (wrapperHook == 'useMemo') { - builder.addSimpleInsertion(construction.initializer.offset, '$wrapperHook(() => '); - builder.addSimpleInsertion(construction.initializer.end, ')'); - } else { - builder.addSimpleInsertion(construction.initializer.offset, '$wrapperHook('); - // Add a placeholder here so there isn't a static error about using useCallback with the wrong number of arguments. - // FIXME(greg) figure out if this is the right way to handle this. - builder.addSimpleInsertion(construction.initializer.end, ', [/* FIXME add dependencies */])'); - } - }, - ), - ); - } else { - // TODO: What if the function needs to change on every render anyway? - // Should we suggest removing effect deps as an appropriate fix too? - diagnosticCollector.addError( - HooksExhaustiveDeps.code, - result.locationFor(construction), - errorMessageArgs: [message], - ); - } - }); - return; - } - - // If we're going to report a missing dependency, - // we might as well recalculate the list ignoring - // the currently specified deps. This can result - // in some extra deduplication. We can't do this - // for effects though because those have legit - // use cases for over-specifying deps. - if (!isEffect && missingDependencies.isNotEmpty) { - suggestedDeps = collectRecommendations( - dependencies: dependencies, - declaredDependencies: [], - // Pretend we don't know - stableDependencies: stableDependencies, - externalDependencies: externalDependencies, - isEffect: isEffect, - ).suggestedDependencies; - } - - // Alphabetize the suggestions, but only if deps were already alphabetized. - if (declaredDependencies.map((dep) => dep.key).isSorted) { - suggestedDeps.sort(); - } - - // Most of our algorithm deals with dependency paths with optional chaining stripped. - // This function is the last step before printing a dependency, so now is a good time to - // check whether any members in our path are always used as optional-only. In that case, - // we will use ?. instead of . to concatenate those parts of the path. - String formatDependency(String path) { - final members = path.split('.'); - var finalPath = ''; - for (var i = 0; i < members.length; i++) { - if (i != 0) { - final pathSoFar = members.sublist(0, i + 1).join('.'); - final isOptional = optionalChains[pathSoFar] == true; - finalPath += isOptional ? '?.' : '.'; - } - finalPath += members[i]; - } - return finalPath; - } - - String getWarningMessage(Iterable deps, String singlePrefix, String label, String fixVerb) { - if (deps.isEmpty) { - return null; - } - return (deps.length > 1 ? '' : singlePrefix + ' ') + - label + - ' ' + - (deps.length > 1 ? 'dependencies' : 'dependency') + - ': ' + - joinEnglish((deps.toList()..sort()).map((name) => "'" + formatDependency(name) + "'").toList()) + - ". Either $fixVerb ${deps.length > 1 ? 'them' : 'it'} or remove the dependency array."; - } - - String extraWarning; - if (unnecessaryDependencies.isNotEmpty) { - final badRef = unnecessaryDependencies.firstWhere((key) => key.endsWith('.current'), orElse: () => null); - if (badRef != null) { - extraWarning = " Mutable values like '$badRef' aren't valid dependencies " - "because mutating them doesn't re-render the component."; - } else if (externalDependencies.isNotEmpty) { - // FIXME store actual reference to dep instead of just string representation - // final dep = externalDependencies.first; - // // Don't show this warning for things that likely just got moved *inside* the callback - // // because in that case they're clearly not referring to globals. - // if (!scope.set.contains(dep)) { - // extraWarning = - // " Outer scope values like '$dep' aren't valid dependencies " - // "because mutating them doesn't re-render the component."; - // } - } - } - - // FIXME(greg) change this behavior since tearoffs bind `this` in Dart, and also worrying about props being called with `this` isn't worth the confusion this messaging brings - // "props.foo()" marks "props" as a dependency because it has - // a "this" value. This warning can be confusing. - // So if we're going to show it, append a clarification. - if (extraWarning == null && missingDependencies.contains('props')) { - final propDep = dependencies['props']; - if (propDep == null) { - return; - } - final refs = propDep.references; - if (refs == null) { - return; - } - var isPropsOnlyUsedInMembers = true; - for (final ref in refs) { - if (ref == null) { - isPropsOnlyUsedInMembers = false; - break; - } - final parent = ref.parent; - if (parent == null) { - isPropsOnlyUsedInMembers = false; - break; - } - if (!(parent is PropertyAccess && parent.target == ref) && - !(parent is MethodInvocation && parent.target == ref)) { - isPropsOnlyUsedInMembers = false; - break; - } - } - if (isPropsOnlyUsedInMembers) { - extraWarning = " However, 'props' will change when *any* prop changes, so the " - "preferred fix is to destructure the 'props' object outside of " - "the $reactiveHookName call and refer to those specific props " - "inside ${getSource(reactiveHook)}."; - } - } - - if (extraWarning == null && missingDependencies.isNotEmpty) { - // See if the user is trying to avoid specifying a callable prop. - // This usually means they're unaware of useCallback. - String missingCallbackDep; - // FIXME make dependencies use identifiers and not just strings so we can resolve them - // ignore_for_file: avoid_function_literals_in_foreach_calls - // missingDependencies.forEach((missingDep) { - // if (missingCallbackDep != null) { - // return; - // } - // // Is this a variable from top scope? - // final topScopeRef = componentScope.set.get(missingDep); - // final usedDep = dependencies[missingDep]; - // if (usedDep.references[0].resolved != topScopeRef) { - // return; - // } - // // Is this a destructured prop? - // final def = topScopeRef.defs[0]; - // if (def == null || def.name == null || def.type != 'Parameter') { - // return; - // } - // // Was it called in at least one case? Then it's a function. - // var isFunctionCall = false; - // for (final id in usedDep.references) { - // if (id?.parent?.tryCast()?.function == id) { - // isFunctionCall = true; - // break; - // } - // } - // if (!isFunctionCall) { - // return; - // } - // // If it's missing (i.e. in component scope) *and* it's a parameter - // // then it is definitely coming from props destructuring. - // // (It could also be props itself but we wouldn't be calling it then.) - // missingCallbackDep = missingDep; - // }); - if (missingCallbackDep != null) { - extraWarning = " If '$missingCallbackDep' changes too often, " - "find the parent component that defines it " - "and wrap that definition in useCallback."; - } - } - - if (extraWarning == null && missingDependencies.isNotEmpty) { - _SetStateRecommendation setStateRecommendation; - missingDependencies.forEach((missingDep) { - if (setStateRecommendation != null) { - return; - } - final usedDep = dependencies[missingDep]; - final references = usedDep.references; - for (final id in references) { - var maybeCall = id.parent; - // Try to see if we have setState(someExpr(missingDep)). - while (maybeCall != null && maybeCall != componentFunction.body) { - if (maybeCall is InvocationExpression) { - final maybeCallFunction = maybeCall.function; - final maybeCallFunctionName = maybeCallFunction.tryCast()?.methodName?.name ?? - maybeCallFunction.tryCast()?.name; - final correspondingStateVariable = setStateCallSites.get( - maybeCallFunction.tryCast(), - ); - if (correspondingStateVariable != null) { - if (correspondingStateVariable.name.name == missingDep) { - // setCount(count + 1) - setStateRecommendation = _SetStateRecommendation( - missingDep: missingDep, - setter: maybeCallFunctionName, - form: _SetStateRecommendationForm.updater, - ); - } else if (stateVariables.has(id)) { - // setCount(count + increment) - setStateRecommendation = _SetStateRecommendation( - missingDep: missingDep, - setter: maybeCallFunctionName, - form: _SetStateRecommendationForm.reducer, - ); - } else { - // If it's a parameter *and* a missing dep, - // it must be a prop or something inside a prop. - // Therefore, recommend an inline reducer. - final def = id.staticElement; - if (def != null && def is ParameterElement) { - setStateRecommendation = _SetStateRecommendation( - missingDep: missingDep, - setter: maybeCallFunctionName, - form: _SetStateRecommendationForm.inlineReducer, - ); - } - } - break; - } - } - maybeCall = maybeCall.parent; - } - if (setStateRecommendation != null) { - break; - } - } - }); - if (setStateRecommendation != null) { - switch (setStateRecommendation.form) { - case _SetStateRecommendationForm.reducer: - extraWarning = " You can also replace multiple useState variables with useReducer " - "if '${setStateRecommendation.setter}' needs the " - "current value of '${setStateRecommendation.missingDep}'."; - break; - case _SetStateRecommendationForm.inlineReducer: - extraWarning = " If '${setStateRecommendation.setter}' needs the " - "current value of '${setStateRecommendation.missingDep}', " - "you can also switch to useReducer instead of useState and " - "read '${setStateRecommendation.missingDep}' in the reducer."; - break; - case _SetStateRecommendationForm.updater: - extraWarning = - " You can also do a functional update '${setStateRecommendation.setter}(${setStateRecommendation.missingDep.substring( - 0, - 1, - )} => ...)' if you only need '${setStateRecommendation.missingDep}'" - " in the '${setStateRecommendation.setter}' call."; - break; - } - } - } - - // FIXME this is async :/ - diagnosticCollector.addErrorWithFix( - HooksExhaustiveDeps.code, - result.locationFor(declaredDependenciesNode), - errorMessageArgs: [ - "React Hook ${getSource(reactiveHook)} has " + - // To avoid a long message, show the next actionable item. - (getWarningMessage(missingDependencies, 'a', 'missing', 'include') ?? - getWarningMessage(unnecessaryDependencies, 'an', 'unnecessary', 'exclude') ?? - getWarningMessage(duplicateDependencies, 'a', 'duplicate', 'omit') ?? - '') + - (extraWarning ?? ''), - ], - fixKind: HooksExhaustiveDeps.fixKind, - fixMessageArgs: ["Update the dependencies array to be: [${suggestedDeps.map(formatDependency).join(', ')}]"], - computeFix: () => buildGenericFileEdit(result, (e) { - e.addSimpleReplacement( - range.node(declaredDependenciesNode), "[${suggestedDeps.map(formatDependency).join(', ')}]"); - }), - ); - } - - @override - void visitInvocationExpression(InvocationExpression node) { - super.visitInvocationExpression(node); - - final callbackIndex = getReactiveHookCallbackIndex(node.function, additionalHooks: additionalHooks); - if (callbackIndex == -1) { - // Not a React Hook call that needs deps. - return; - } - final callback = node.argumentList.arguments.elementAtOrNull(callbackIndex); - final reactiveHook = node.function; - final reactiveHookName = getNodeWithoutReactNamespace(reactiveHook).toSource(); - final declaredDependenciesNode = node.argumentList.arguments.elementAtOrNull(callbackIndex + 1); - final isEffect = RegExp(r'Effect($|[^a-z])').hasMatch(reactiveHookName); - - // Check the declared dependencies for this reactive hook. If there is no - // second argument then the reactive callback will re-run on every render. - // So no need to check for dependency inclusion. - if (declaredDependenciesNode == null && !isEffect) { - // These are only used for optimization. - if (reactiveHookName == 'useMemo' || reactiveHookName == 'useCallback') { - // TODO: Can this have a suggestion? - reportProblem( - node: reactiveHook, - message: "React Hook $reactiveHookName does nothing when called with " - "only one argument. Did you forget to pass an array of " - "dependencies?", - ); - } - return; - } - - if (callback is FunctionExpression) { - visitFunctionWithDependencies( - node: callback.body, - declaredDependenciesNode: declaredDependenciesNode, - reactiveHook: reactiveHook, - reactiveHookName: reactiveHookName, - isEffect: isEffect, - ); - return; // Handled - } else if (callback is Identifier) { - if (declaredDependenciesNode == null) { - // No deps, no problems. - return; // Handled - } - // The function passed as a callback is not written inline. - // But perhaps it's in the dependencies array? - if (declaredDependenciesNode is ListLiteral && - declaredDependenciesNode.elements.whereType().any( - (el) => el.name == callback.name, - )) { - // If it's already in the list of deps, we don't care because - // this is valid regardless. - return; // Handled - } - // We'll do our best effort to find it, complain otherwise. - final declaration = callback.staticElement?.declaration; - if (declaration == null) { - // If it's not in scope, we don't care. - return; // Handled - } - // The function passed as a callback is not written inline. - // But it's defined somewhere in the render scope. - // We'll do our best effort to find and check it, complain otherwise. - final function = lookUpFunction(declaration, callback.root); - if (function != null) { - // effectBody() {...}; - // // or - // final effectBody = () {...}; - // useEffect(() => { ... }, []); - visitFunctionWithDependencies( - node: function.body, - declaredDependenciesNode: declaredDependenciesNode, - reactiveHook: reactiveHook, - reactiveHookName: reactiveHookName, - isEffect: isEffect, - ); - return; // Handled - } - // Unhandled - } else { - // useEffect(generateEffectBody(), []); - reportProblem( - node: reactiveHook, - message: "React Hook $reactiveHookName received a whose dependencies " - "are unknown. Pass an inline instead.", - ); - return; // Handled - } - - // Something unusual. Fall back to suggesting to add the body itself as a dep. - final callbackName = (callback as Identifier).name; - // FIXME this is async :/ - diagnosticCollector.addErrorWithFix( - HooksExhaustiveDeps.code, - result.locationFor(reactiveHook), - errorMessageArgs: [ - // ignore: prefer_adjacent_string_concatenation - "React Hook $reactiveHookName has a missing dependency: '$callbackName'. " + - "Either include it or remove the dependency array." - ], - fixKind: HooksExhaustiveDeps.fixKind, - fixMessageArgs: ["Update the dependencies array to be: [$callbackName]"], - computeFix: () => buildGenericFileEdit(result, (e) { - e.addSimpleReplacement(range.node(declaredDependenciesNode), "[$callbackName]"); - }), - ); - } -} - -enum _SetStateRecommendationForm { - reducer, - inlineReducer, - updater, -} - -class _SetStateRecommendation { - final String missingDep; - final String setter; - final _SetStateRecommendationForm form; - - _SetStateRecommendation({this.missingDep, this.setter, this.form}); -} - -extension on AstNode { - T parentOfType() { - final parent = this.parent; - return parent is T ? parent : null; - } -} - -class _Recommendations { - final List suggestedDependencies; - final Set unnecessaryDependencies; - final Set missingDependencies; - final Set duplicateDependencies; - - _Recommendations({ - this.suggestedDependencies, - this.unnecessaryDependencies, - this.missingDependencies, - this.duplicateDependencies, - }); -} - -class _DepTree { - /// True if used in code - bool isUsed; - - /// True if specified in deps - bool isSatisfiedRecursively; - - /// True if something deeper is used by code - bool isSubtreeUsed; - - // Nodes for properties - final Map children; - - _DepTree({this.isUsed, this.isSatisfiedRecursively, this.isSubtreeUsed, this.children}); -} - -class _DeclaredDependency { - final String key; - final Expression node; - - final Element debugEnclosingElement; - - _DeclaredDependency(this.key, this.node, {this.debugEnclosingElement}); - - @override - String toString() => prettyString({ - 'key': key, - 'node': node, - 'debugEnclosingElement': debugEnclosingElement?.debugString, - }); -} - -extension on Element { - String get debugString => '$runtimeType<$id, ${getDisplayString(withNullability: false)}>'; -} - -// The meat of the logic. -_Recommendations collectRecommendations({ - @required Map dependencies, - @required List<_DeclaredDependency> declaredDependencies, - @required Iterable stableDependencies, - @required Set externalDependencies, - @required bool isEffect, -}) { - // Our primary data structure. - // It is a logical representation of property chains: - // "props" -> "props.foo" -> "props.foo.bar" -> "props.foo.bar.baz" - // -> "props.lol" - // -> "props.huh" -> "props.huh.okay" - // -> "props.wow" - // We'll use it to mark nodes that are *used* by the programmer, - // and the nodes that were *declared* as deps. Then we will - // traverse it to learn which deps are missing or unnecessary. - _DepTree createDepTree() { - return _DepTree( - isUsed: false, // True if used in code - isSatisfiedRecursively: false, // True if specified in deps - isSubtreeUsed: false, // True if something deeper is used by code - children: {}, // Nodes for properties - ); - } - - final depTree = createDepTree(); - - // Tree manipulation helpers. - _DepTree getOrCreateNodeByPath(_DepTree rootNode, String path) { - final keys = path.split('.'); - var node = rootNode; - for (final key in keys) { - var child = node.children[key]; - if (child == null) { - child = createDepTree(); - node.children[key] = child; - } - node = child; - } - return node; - } - - void markAllParentsByPath(_DepTree rootNode, String path, void Function(_DepTree) fn) { - final keys = path.split('.'); - var node = rootNode; - for (final key in keys) { - final child = node.children[key]; - if (child == null) { - return; - } - fn(child); - node = child; - } - } - - // Mark all required nodes first. - // Imagine exclamation marks next to each used deep property. - dependencies.forEach((key, _) { - final node = getOrCreateNodeByPath(depTree, key); - node.isUsed = true; - markAllParentsByPath(depTree, key, (parent) { - parent.isSubtreeUsed = true; - }); - }); - - // Mark all satisfied nodes. - // Imagine checkmarks next to each declared dependency. - declaredDependencies.forEach((_entry) { - final key = _entry.key; - final node = getOrCreateNodeByPath(depTree, key); - node.isSatisfiedRecursively = true; - }); - stableDependencies.forEach((key) { - final node = getOrCreateNodeByPath(depTree, key); - node.isSatisfiedRecursively = true; - }); - - // Now we can learn which dependencies are missing or necessary. - final missingDependencies = {}; - final satisfyingDependencies = {}; - void scanTreeRecursively( - _DepTree node, Set missingPaths, Set satisfyingPaths, String Function(String) keyToPath) { - node.children.forEach((key, child) { - final path = keyToPath(key); - if (child.isSatisfiedRecursively) { - if (child.isSubtreeUsed) { - // Remember this dep actually satisfied something. - satisfyingPaths.add(path); - } - // It doesn't matter if there's something deeper. - // It would be transitively satisfied since we assume immutability. - // "props.foo" is enough if you read "props.foo.id". - return; - } - if (child.isUsed) { - // Remember that no declared deps satisfied this node. - missingPaths.add(path); - // If we got here, nothing in its subtree was satisfied. - // No need to search further. - return; - } - scanTreeRecursively( - child, - missingPaths, - satisfyingPaths, - (childKey) => path + '.' + childKey, - ); - }); - } - - scanTreeRecursively( - depTree, - missingDependencies, - satisfyingDependencies, - (key) => key, - ); - - // Collect suggestions in the order they were originally specified. - final suggestedDependencies = []; - final unnecessaryDependencies = {}; - final duplicateDependencies = {}; - declaredDependencies.forEach((_entry) { - final key = _entry.key; - // Does this declared dep satisfy a real need? - if (satisfyingDependencies.contains(key)) { - if (!suggestedDependencies.contains(key)) { - // Good one. - suggestedDependencies.add(key); - } else { - // Duplicate. - duplicateDependencies.add(key); - } - } else { - if (isEffect && !key.endsWith('.current') && !externalDependencies.contains(key)) { - // Effects are allowed extra "unnecessary" deps. - // Such as resetting scroll when ID changes. - // Consider them legit. - // The exception is ref.current which is always wrong. - if (!suggestedDependencies.contains(key)) { - suggestedDependencies.add(key); - } - } else { - // It's definitely not needed. - unnecessaryDependencies.add(key); - } - } - }); - - // Then add the missing ones at the end. - suggestedDependencies.addAll(missingDependencies); - - return _Recommendations( - suggestedDependencies: suggestedDependencies, - unnecessaryDependencies: unnecessaryDependencies, - duplicateDependencies: duplicateDependencies, - missingDependencies: missingDependencies, - ); -} - -// If the node will result in constructing a referentially unique value, return -// its human readable type name, else return null. -String getConstructionExpressionType(Expression node) { - if (node is InstanceCreationExpression) { - if (node.isConst) return null; - return node.constructorName.type.name.name; - } else if (node is ListLiteral) { - return 'List'; - } else if (node is SetOrMapLiteral) { - return node.isMap ? 'Map' : 'Set'; - } else if (node is FunctionExpression) { - return 'function'; - } else if (node is ConditionalExpression) { - if (getConstructionExpressionType(node.thenExpression) != null || - getConstructionExpressionType(node.elseExpression) != null) { - return 'conditional'; - } - return null; - } else if (node is BinaryExpression) { - if (getConstructionExpressionType(node.leftOperand) != null || - getConstructionExpressionType(node.rightOperand) != null) { - return 'binary expression'; - } - return null; - } else if (node is AssignmentExpression) { - if (getConstructionExpressionType(node.rightHandSide) != null) { - return 'assignment expression'; - } - return null; - } else if (node is AsExpression) { - return getConstructionExpressionType(node.expression); - } else if (node is InvocationExpression && node.staticType.isReactElement) { - return 'ReactElement'; - } else { - return null; - } - // todo what about function calls...? -} - -// Finds variables declared as dependencies -// that would invalidate on every render. -List<_Construction> scanForConstructions({ - @required Iterable<_DeclaredDependency> declaredDependencies, - @required AstNode declaredDependenciesNode, -}) { - final constructions = declaredDependencies.map>((dep) { - // FIXME this should be equivalent, but need to figure out how chained properties work... I'm not sure how that would work with analyzePropertyChain being used with the existing code to look up identifiers - // final ref = componentScope.variables.firstWhere((v) => v.name == key, orElse: () => null); - final refElement = dep.node?.tryCast()?.staticElement; - if (refElement == null) return null; - - final declaration = lookUpDeclaration(refElement, dep.node.root); - if (declaration == null) { - return null; - } - // final handleChange = () {}; - // final foo = {}; - // final foo = []; - // etc. - if (declaration is VariableDeclaration) { - // Const variables never change - if (declaration.isConst) return null; - if (declaration.initializer != null) { - // todo should this be stricter in Dart? - final constantExpressionType = getConstructionExpressionType( - declaration.initializer, - ); - if (constantExpressionType != null) { - return Tuple2(declaration, constantExpressionType); - } - } - return null; - } - // handleChange() {} - if (declaration is FunctionDeclaration) { - return Tuple2(declaration, _DepType.function); - } - - return null; - }).whereNotNull(); - - bool isUsedOutsideOfHook(Declaration declaration) { - for (final reference in findReferences(declaration.declaredElement, declaration.root)) { - // TODO better implementation of this - // Crude implementation of ignoring initializer - if (declaration.containsRangeOf(reference)) { - continue; - } - - final parent = reference.parent; - if (parent is AssignmentExpression && parent.leftHandSide == reference) { - return true; - } - - // This reference is outside the Hook callback. - // It can only be legit if it's the deps array. - if (!declaredDependenciesNode.containsRangeOf(reference)) { - return true; - } - } - return false; - } - - return constructions.map((tuple) { - final declaration = tuple.item1; - final depType = tuple.item2; - return _Construction( - declaration: declaration, - depType: depType, - isUsedOutsideOfHook: isUsedOutsideOfHook(declaration), - ); - }).toList(); -} - -class _Construction { - final Declaration declaration; - final String depType; - final bool isUsedOutsideOfHook; - - _Construction({this.declaration, this.depType, this.isUsedOutsideOfHook}); -} - -abstract class _DepType { - // static const classDep = 'class'; - static const function = 'function'; -} - -// fixme Greg this doc comment has to be incorrect for some cases, right? -/// Assuming () means the passed/returned node: -/// (props) => (props) -/// props.(foo) => (props.foo) -/// props.foo.(bar) => (props).foo.bar -/// props.foo.bar.(baz) => (props).foo.bar.baz -AstNode getDependency(AstNode node) { - final parent = node.parent; - final grandparent = parent.parent; - - if (parent is PropertyAccess && - parent.target == node && - parent.propertyName.name != 'current' && - !(grandparent is InvocationExpression && - // fixme is this right? - grandparent.function == parent)) { - return getDependency(parent); - } - if (parent is PrefixedIdentifier && - parent.prefix == node && - parent.identifier.name != 'current' && - !(grandparent is InvocationExpression && - // fixme is this right? - grandparent.function == parent)) { - return getDependency(parent); - } - - if (node is PropertyAccess && parent is AssignmentExpression && parent.leftHandSide == node) { - return node.target; - } - if (node is PrefixedIdentifier && parent is AssignmentExpression && parent.leftHandSide == node) { - return node.prefix; - } - - return node; -} - -List findReferences(Element element, AstNode root) { - final visitor = ReferenceVisitor(element); - root.accept(visitor); - return visitor.references; -} - -class ReferenceVisitor extends RecursiveAstVisitor { - final Element _targetElement; - - final List references = []; - - ReferenceVisitor(this._targetElement); - - @override - void visitSimpleIdentifier(SimpleIdentifier node) { - super.visitSimpleIdentifier(node); - - if (node.staticElement == _targetElement) { - references.add(node); - } - } -} - -/// Mark a node as either optional or required. -/// Note: If the node argument is an OptionalMemberExpression, it doesn't necessarily mean it is optional. -/// It just means there is an optional member somewhere inside. -/// This particular node might still represent a required member, so check .optional field. -void markNode(AstNode node, Map optionalChains, String result, {@required bool isOptional}) { - if (optionalChains != null && isOptional == null) { - throw ArgumentError('isOptional must be specified when optionalChains is'); - } - - if (optionalChains != null) { - if (isOptional) { - // We only want to consider it optional if *all* usages were optional. - if (!optionalChains.containsKey(result)) { - // Mark as (maybe) optional. If there's a required usage, this will be overridden. - optionalChains[result] = true; - } - } else { - // Mark as required. - optionalChains[result] = false; - } - } -} - -/// Assuming () means the passed node. -/// (foo) -> 'foo' -/// foo(.)bar -> 'foo.bar' -/// foo.bar(.)baz -> 'foo.bar.baz' -/// Otherwise throw. -String analyzePropertyChain(AstNode node, Map optionalChains) { - if (node is SimpleIdentifier) { - final result = node.name; - if (optionalChains != null) { - // Mark as required. - optionalChains[result] = false; - } - return result; - } else if (node is PropertyAccess) { - final object = analyzePropertyChain(node.target, optionalChains); - final property = analyzePropertyChain(node.propertyName, null); - final result = "$object.$property"; - markNode(node, optionalChains, result, isOptional: node.isNullAware); - return result; - } else if (node is PrefixedIdentifier) { - final object = analyzePropertyChain(node.prefix, optionalChains); - final property = analyzePropertyChain(node.identifier, null); - final result = "$object.$property"; - markNode(node, optionalChains, result, isOptional: false); - return result; - } else { - throw ArgumentError("Unsupported node type: ${node.runtimeType}"); - } -} - -// todo unit test this and remove extraneous cases -AstNode getNodeWithoutReactNamespace(Expression node) { - if (node is PrefixedIdentifier) { - if (node.prefix.staticElement is LibraryElement) {} - } else if (node is PropertyAccess) { - if (node.realTarget?.tryCast()?.staticElement is LibraryElement) { - return node.propertyName; - } - } - return node; -} - -// What's the index of callback that needs to be analyzed for a given Hook? -// -1 if it's not a Hook we care about (e.g. useState). -// 0 for useEffect/useMemo/useCallback(fn). -// 1 for useImperativeHandle(ref, fn). -// For additionally configured Hooks, assume that they're like useEffect (0). -int getReactiveHookCallbackIndex(Expression calleeNode, {RegExp additionalHooks}) { - final node = getNodeWithoutReactNamespace(calleeNode); - if (node is! Identifier) { - return -1; - } - switch ((node as Identifier).name) { - case 'useEffect': - case 'useLayoutEffect': - case 'useCallback': - case 'useMemo': - // useEffect(fn) - return 0; - case 'useImperativeHandle': - // useImperativeHandle(ref, fn) - return 1; - default: - if (node == calleeNode && additionalHooks != null) { - // Allow the user to provide a regular expression which enables the lint to - // target custom reactive hooks. - String name; - try { - name = analyzePropertyChain(node, null); - } on Object catch (error) { - if (error.toString().contains('Unsupported node type')) { - return 0; - } else { - rethrow; - } - } - return additionalHooks.hasMatch(name) ? 0 : -1; - } else { - return -1; - } - } -} - -String joinEnglish(List arr) { - var s = ''; - for (var i = 0; i < arr.length; i++) { - s += arr[i]; - if (i == 0 && arr.length == 2) { - s += ' and '; - } else if (i == arr.length - 2 && arr.length > 2) { - s += ', and '; - } else if (i < arr.length - 1) { - s += ', '; - } - } - return s; -} - -extension> on Iterable { - /// Whether the elements in this collection are already sorted. - bool get isSorted { - var isFirst = true; - E prev; - for (final element in this) { - if (isFirst) { - isFirst = false; - } else if (prev.compareTo(element) > 0) { - return false; - } - prev = element; - } - return true; - } -} - -extension on Element { - // ignore: unused_element - Iterable get ancestors sync* { - final enclosingElement = this.enclosingElement; - if (enclosingElement != null) { - yield enclosingElement; - yield* enclosingElement.ancestors; - } - } -} - -extension on List { - /// Returns the element at [index], or `null` if the index is greater than the length of the list. - E elementAtOrNull(int index) => index < length ? this[index] : null; -} - -// Adapted from over_react's prettyPrintMap - -const String nonBreakingSpace = '\u00a0'; -// Use non-breaking spaces so leading spaces show up in IDE tooltips. -const String _indent = '$nonBreakingSpace$nonBreakingSpace'; -const int _maxKeyValuePairsPerLine = 2; -const int _maxListItemsPerLine = 4; -const int _maxSingleLineListOrMapLength = 50; - -const namespaceThreshold = 2; -const namespaceSeparator = '.'; - -/// Indents [str] by [_indent], trimming any trailing whitespace. -String _indentString(String str) { - return str.split('\n').map((line) => (_indent + line).trimRight()).join('\n'); -} - -String prettyString(Object obj) { - if (obj is List) { - var items = obj.map(prettyString).toList(); - - final singleLineInner = items.join(', '); - if (items.length > _maxListItemsPerLine || - items.any((items) => items.contains('\n')) || - singleLineInner.length > _maxSingleLineListOrMapLength) { - var inner = _indentString(items.join(',\n')); - return '[\n$inner\n]'; - } else { - return '[$singleLineInner]'; - } - } else if (obj is Map) { - final namespacedKeys = >{}; - final otherKeys = []; - - final shouldNamespace = obj.keys - .whereType() - .where((key) => key.contains(namespaceSeparator)) - .hasLengthOfAtLeast(namespaceThreshold); - - obj.keys.forEach((dynamic key) { - if (shouldNamespace && key is String && key.contains(namespaceSeparator)) { - var index = key.indexOf(namespaceSeparator); - var namespace = key.substring(0, index); - var subkey = key.substring(index); - - namespacedKeys[namespace] ??= []; - namespacedKeys[namespace].add(subkey); - } else { - otherKeys.add(key); - } - }); - - final pairs = []; - - pairs.addAll(namespacedKeys.keys.map((namespace) { - String renderSubKey(String subkey) { - var key = '$namespace$subkey'; - dynamic value = obj[key]; - - return '$subkey: ' + prettyString(value); - } - - Iterable subkeys = namespacedKeys[namespace]; - - return '$namespace…\n' + _indentString(subkeys.map(renderSubKey).map((pair) => pair + ',\n').join()); - })); - - pairs.addAll(otherKeys.map((dynamic key) { - return '$key: ' + prettyString(obj[key]) + ','; - })); - - final trailingComma = RegExp(r'\s*,\s*$'); - - final singleLineInner = pairs.join(' ').replaceFirst(trailingComma, ''); - - if (pairs.length > _maxKeyValuePairsPerLine || - pairs.any((pair) => pair.contains('\n')) || - singleLineInner.length > _maxSingleLineListOrMapLength) { - var inner = _indentString(pairs.join('\n')).replaceFirst(trailingComma, ''); - return '{\n$inner\n}'; - } else { - return '{$singleLineInner}'; - } - } else { - return obj.toString(); - } -} - -extension on Iterable { - bool hasLengthOfAtLeast(int length) => take(length).length == length; -} diff --git a/tools/analyzer_plugin/lib/src/diagnostic/non_defaulted_prop.dart b/tools/analyzer_plugin/lib/src/diagnostic/non_defaulted_prop.dart index 18ac68644..92351250d 100644 --- a/tools/analyzer_plugin/lib/src/diagnostic/non_defaulted_prop.dart +++ b/tools/analyzer_plugin/lib/src/diagnostic/non_defaulted_prop.dart @@ -123,7 +123,7 @@ class NonDefaultedPropVisitor extends GeneralizingAstVisitor { // todo probably improve this? if (initializer is BinaryExpression && initializer.operator.type == TokenType.QUESTION_QUESTION) { - final parts = getSimpleTargetAndPropertyName(initializer.leftOperand); + final parts = getSimpleTargetAndPropertyName(initializer.leftOperand, allowMethodInvocation: true); if (parts != null) { final targetName = parts.item1; if (targetName.name == 'props') { @@ -147,13 +147,8 @@ class NonDefaultedPropVisitor extends GeneralizingAstVisitor { void visitPropertyAccess(PropertyAccess node) { super.visitPropertyAccess(node); - final parts = getSimpleTargetAndPropertyName(node); - if (parts != null) { - final targetName = parts.item1; - if (targetName.name == 'props') { - final propertyName = parts.item2; - propAccessesByName.putIfAbsent(propertyName.name, () => []).add(node); - } + if (node.target.tryCast()?.name == 'props') { + propAccessesByName.putIfAbsent(node.propertyName.name, () => []).add(node); } } } diff --git a/tools/analyzer_plugin/lib/src/doc_utils/documented_contributor_meta.dart b/tools/analyzer_plugin/lib/src/doc_utils/documented_contributor_meta.dart index de592d1cd..3583ec042 100644 --- a/tools/analyzer_plugin/lib/src/doc_utils/documented_contributor_meta.dart +++ b/tools/analyzer_plugin/lib/src/doc_utils/documented_contributor_meta.dart @@ -32,7 +32,7 @@ import 'package:analyzer_plugin/protocol/protocol_common.dart'; import 'package:over_react_analyzer_plugin/src/diagnostic_contributor.dart'; import 'package:over_react_analyzer_plugin/src/doc_utils/maturity.dart'; import 'package:over_react_analyzer_plugin/src/doc_utils/docs_meta_annotation.dart'; -import 'package:over_react_analyzer_plugin/src/util/ast_util.dart'; +import 'package:over_react_analyzer_plugin/src/util/constant_instantiation.dart'; /// An interface implemented by registered metadata models and the annotation(s) /// that enable contribution to those models. diff --git a/tools/analyzer_plugin/lib/src/plugin.dart b/tools/analyzer_plugin/lib/src/plugin.dart index fc65d4294..5358c22a2 100644 --- a/tools/analyzer_plugin/lib/src/plugin.dart +++ b/tools/analyzer_plugin/lib/src/plugin.dart @@ -33,6 +33,7 @@ import 'dart:async'; import 'package:analyzer/dart/analysis/context_builder.dart'; import 'package:analyzer/dart/analysis/context_locator.dart'; +import 'package:analyzer/dart/analysis/context_root.dart' as analyzer; import 'package:analyzer/file_system/file_system.dart'; // ignore: implementation_imports import 'package:analyzer/src/dart/analysis/context_builder.dart' show ContextBuilderImpl; @@ -42,6 +43,8 @@ import 'package:analyzer_plugin/plugin/navigation_mixin.dart'; import 'package:analyzer_plugin/plugin/plugin.dart'; import 'package:analyzer_plugin/protocol/protocol_generated.dart' as plugin; import 'package:analyzer_plugin/utilities/navigation/navigation.dart'; +import 'package:over_react_analyzer_plugin/src/analysis_options/plugin_analysis_options.dart'; +import 'package:over_react_analyzer_plugin/src/analysis_options/reader.dart'; import 'package:over_react_analyzer_plugin/src/assist/add_props.dart'; import 'package:over_react_analyzer_plugin/src/assist/convert_class_or_function_component.dart'; import 'package:over_react_analyzer_plugin/src/assist/extract_component.dart'; @@ -57,6 +60,7 @@ import 'package:over_react_analyzer_plugin/src/diagnostic/consumed_props_return_ import 'package:over_react_analyzer_plugin/src/diagnostic/create_ref_usage.dart'; import 'package:over_react_analyzer_plugin/src/diagnostic/dom_prop_types.dart'; import 'package:over_react_analyzer_plugin/src/diagnostic/duplicate_prop_cascade.dart'; +import 'package:over_react_analyzer_plugin/src/diagnostic/exhaustive_deps.dart'; import 'package:over_react_analyzer_plugin/src/diagnostic/forward_only_dom_props_to_dom_builders.dart'; import 'package:over_react_analyzer_plugin/src/diagnostic/incorrect_doc_comment_location.dart'; import 'package:over_react_analyzer_plugin/src/diagnostic/invalid_child.dart'; @@ -84,6 +88,9 @@ abstract class OverReactAnalyzerPluginBase extends ServerPlugin AsyncDartAssistsMixin { OverReactAnalyzerPluginBase(ResourceProvider provider) : super(provider); + @override + final pluginOptionsReader = PluginOptionsReader(); + @override List get fileGlobsToAnalyze => const ['*.dart']; @@ -107,37 +114,61 @@ abstract class OverReactAnalyzerPluginBase extends ServerPlugin @override List getNavigationContributors(String path) => []; + // TODO(greg) is there a better way to do this? + analyzer.ContextRoot? _analyzerContextRootForPath(String path) { + final driver = driverForPath(path); + if (driver == null) return null; + + // TODO should this throw? + if (driver is! AnalysisDriver) return null; + + return driver.analysisContext?.contextRoot; + } + + PluginAnalysisOptions? optionsForPath(String path) { + // Do not use protocol.ContextRoot's optionsFile, since it's null at least in tests. + // We'll use te driver's context instead. + final contextRoot = _analyzerContextRootForPath(path); + if (contextRoot == null) return null; + return pluginOptionsReader.getOptionsForContextRoot(contextRoot); + } + @override - List getDiagnosticContributors(String path) => [ - PropTypesReturnValueDiagnostic(), - DuplicatePropCascadeDiagnostic(), - LinkTargetUsageWithoutRelDiagnostic(), - BadKeyDiagnostic(), - VariadicChildrenDiagnostic(), - ArrowFunctionPropCascadeDiagnostic(), - RenderReturnValueDiagnostic(), - InvalidChildDiagnostic(), - StringRefDiagnostic(), - CallbackRefDiagnostic(), - MissingCascadeParensDiagnostic(), - // TODO: Re-enable this once consumers can disable lints via analysis_options.yaml + List getDiagnosticContributors(String path) { + final options = optionsForPath(path); + return [ + PropTypesReturnValueDiagnostic(), + DuplicatePropCascadeDiagnostic(), + LinkTargetUsageWithoutRelDiagnostic(), + BadKeyDiagnostic(), + VariadicChildrenDiagnostic(), + ArrowFunctionPropCascadeDiagnostic(), + RenderReturnValueDiagnostic(), + InvalidChildDiagnostic(), + StringRefDiagnostic(), + CallbackRefDiagnostic(), + MissingCascadeParensDiagnostic(), + // TODO: Re-enable this once consumers can disable lints via analysis_options.yaml // MissingRequiredPropDiagnostic(), - PseudoStaticLifecycleDiagnostic(), - InvalidDomAttributeDiagnostic(), - // TODO: Re-enable this once consumers can disable lints via analysis_options.yaml + PseudoStaticLifecycleDiagnostic(), + InvalidDomAttributeDiagnostic(), + // TODO: Re-enable this once consumers can disable lints via analysis_options.yaml // BoolPropNameReadabilityDiagnostic(), - StyleValueDiagnostic(), - BoilerplateValidatorDiagnostic(), - VariadicChildrenWithKeys(), - IncorrectDocCommentLocationDiagnostic(), - ConsumedPropsReturnValueDiagnostic(), - ForwardOnlyDomPropsToDomBuildersDiagnostic(), - IteratorKey(), - RulesOfHooks(), - // HooksExhaustiveDeps(), - NonDefaultedPropDiagnostic(), - CreateRefUsageDiagnostic(), - ]; + StyleValueDiagnostic(), + BoilerplateValidatorDiagnostic(), + VariadicChildrenWithKeys(), + IncorrectDocCommentLocationDiagnostic(), + ConsumedPropsReturnValueDiagnostic(), + ForwardOnlyDomPropsToDomBuildersDiagnostic(), + IteratorKey(), + RulesOfHooks(), + ExhaustiveDeps( + additionalHooksPattern: options?.exhaustiveDepsAdditionalHooksPattern, + ), + NonDefaultedPropDiagnostic(), + CreateRefUsageDiagnostic(), + ]; + } // @override // List getOutlineContributors(String path) => [ diff --git a/tools/analyzer_plugin/lib/src/util/ast_util.dart b/tools/analyzer_plugin/lib/src/util/ast_util.dart index 6c3dec4f6..d50d965a3 100644 --- a/tools/analyzer_plugin/lib/src/util/ast_util.dart +++ b/tools/analyzer_plugin/lib/src/util/ast_util.dart @@ -2,11 +2,9 @@ library over_react_analyzer_plugin.src.ast_util; import 'dart:collection'; -import 'dart:mirrors'; import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer/dart/ast/visitor.dart'; -import 'package:analyzer/dart/constant/value.dart'; import 'package:analyzer/dart/element/element.dart'; import 'package:analyzer/dart/element/type.dart'; import 'package:analyzer/source/line_info.dart'; @@ -15,11 +13,9 @@ import 'analyzer_util.dart'; import 'constant_evaluator.dart'; import 'util.dart'; -/// Returns an AST associated with the [element] by searching within [root], -/// or null is returned if the [element] is not found. -VariableDeclaration? lookUpVariable(Element? element, AstNode? root) { - if (element == null || root == null) return null; - +/// Returns the AST node of the variable declaration associated with the [element] within [root], +/// or null if the [element] doesn't correspond to a variable declaration, or if it can't be found in [root]. +VariableDeclaration? lookUpVariable(Element element, AstNode root) { final node = NodeLocator2(element.nameOffset).searchWithin(root); if (node is Identifier && node.staticElement == element) { return node.parent.tryCast(); @@ -28,6 +24,50 @@ VariableDeclaration? lookUpVariable(Element? element, AstNode? root) { return null; } +/// Returns the AST node of the function expression corresponding to [element] within [root], +/// which can be either a function declaration or a variable declaration that's +/// initialized to a function expression. +/// +/// Returns null if the [element] doesn't correspond to one of these cases, or if it can't be found in [root]. +FunctionExpression? lookUpFunction(Element element, AstNode root) { + final node = NodeLocator2(element.nameOffset).searchWithin(root); + if (node is Identifier && node.staticElement == element) { + final parent = node.parent; + return parent.tryCast()?.functionExpression ?? + parent.tryCast()?.initializer?.tryCast(); + } + + return null; +} + +/// Returns the AST node that declares [element] within [root], +/// assuming the node that declares it is a `Declaration` instance. +/// +/// Returns null if the [element] doesn't correspond to a `Declaration`, or if it can't be found in [root]. +Declaration? lookUpDeclaration(Element element, AstNode root) { + // if (element is ExecutableElement) return null; + final node = NodeLocator2(element.nameOffset).searchWithin(root); + final declaration = node?.thisOrAncestorOfType(); + if (declaration?.declaredElement == element) { + return declaration; + } + + return null; +} + +/// Returns the AST node that declares the formal parameter [element] within [root]. +/// +/// Returns null if the [element] doesn't correspond to a formal parameter, or if it can't be found in [root]. +FormalParameter? lookUpParameter(Element element, AstNode root) { + final node = NodeLocator2(element.nameOffset).searchWithin(root); + final declaration = node?.thisOrAncestorOfType(); + if (declaration?.declaredElement == element) { + return declaration; + } + + return null; +} + /// Returns a String value when a literal or constant var/identifier is found within [expr]. String? getConstOrLiteralStringValueFrom(Expression expr) { final staticType = expr.staticType; @@ -119,7 +159,10 @@ SimpleIdentifier? propertyNameFromNonCascadedAccessOrInvocation(Expression node) return null; } -Tuple2? getSimpleTargetAndPropertyName(Expression node) { +Tuple2? getSimpleTargetAndPropertyName( + Expression node, { + required bool allowMethodInvocation, +}) { if (node is PrefixedIdentifier) { return Tuple2(node.prefix, node.identifier); } @@ -130,7 +173,7 @@ Tuple2? getSimpleTargetAndPropertyName(Expre return Tuple2(target, node.propertyName); } } - if (node is MethodInvocation) { + if (allowMethodInvocation && node is MethodInvocation) { final target = node.target; if (target is SimpleIdentifier) { return Tuple2(target, node.methodName); @@ -140,6 +183,113 @@ Tuple2? getSimpleTargetAndPropertyName(Expre return null; } +Identifier? getNonCascadedPropertyBeingAccessed(AstNode? node) { + if (node is! Expression) return null; + return getSimpleTargetAndPropertyName(node, allowMethodInvocation: true)?.item2; +} + +/// An abstraction representing an invocation that looks like calling a property on an object, +/// which does not have a single AST representation. +/// +/// For example, it could be a [MethodInvocation], or a [FunctionExpressionInvocation] where the expression is a +/// [PropertyAccess] or [PrefixedIdentifier]. +class PropertyInvocation { + final InvocationExpression invocation; + final Identifier functionName; + final Expression? target; + final Expression? realTarget; + final bool isNullAware; + + PropertyInvocation({ + required this.invocation, + required this.functionName, + required this.target, + required this.realTarget, + required this.isNullAware, + }); + + /// Constructs a property invocation represented by [node], throwing if it doesn't represent one. + /// + /// If you're unsure whether a node is a property invocation, use [detect] instead. + factory PropertyInvocation.from(InvocationExpression node) { + final detected = detect(node); + if (detected == null) { + throw ArgumentError.value(node, 'node', + 'Node does not represent a property invocation. Consider using PropertyInvocation.detect instead, which returns a nullable result.'); + } + return detected; + } + + /// Returns a property invocation for [node], or `null` if it does not represent one. + static PropertyInvocation? detect(AstNode node) { + if (node is! InvocationExpression) return null; + + // TODO(greg) - do we want to restrict target to be certain types? (e.g., rule out `foo.bar.baz()` or `foo().bar()`) + // or should that go in isInvocationADiscreteDependency? + + // TODO(greg) detect .call? + if (node is MethodInvocation) { + return PropertyInvocation( + invocation: node, + functionName: node.methodName, + target: node.target, + realTarget: node.realTarget, + isNullAware: node.isNullAware, + ); + } + + // FunctionExpressionInvocation cases + final function = node.function; + if (function is PropertyAccess) { + return PropertyInvocation( + invocation: node, + functionName: function.propertyName, + target: function.target, + realTarget: function.realTarget, + // TODO(greg) this might not ever this ever be true except for the .call case + isNullAware: function.isNullAware, + ); + } else if (function is PrefixedIdentifier) { + return PropertyInvocation( + invocation: node, + functionName: function.identifier, + target: function.prefix, + realTarget: function.prefix, + isNullAware: false, + ); + } + } + + /// Returns the closest property invocation, starting with [node] and working up its ancestors, that can be + /// [detect]ed, or null if there is none. + static PropertyInvocation? detectClosest(AstNode node) => + detect(node) ?? node.ancestors.map(detect).whereNotNull().firstOrNull; +} + +/// Returns all the identifiers in [root] that represent a resolved reference to [element]. +List allResolvedReferencesTo(Element element, AstNode root) { + final visitor = _ReferenceVisitor(element); + root.accept(visitor); + return visitor.references; +} + +class _ReferenceVisitor extends RecursiveAstVisitor { + final Element _targetElement; + + final List references = []; + + _ReferenceVisitor(this._targetElement); + + @override + void visitSimpleIdentifier(SimpleIdentifier node) { + super.visitSimpleIdentifier(node); + + if (node.staticElement == _targetElement) { + references.add(node); + } + } +} + bool isAConstantValue(Expression expr) { if (expr is SetOrMapLiteral) return expr.isConst; if (expr is ListLiteral) return expr.isConst; @@ -176,6 +326,7 @@ extension FunctionBodyUtils on FunctionBody { FunctionExpression? get parentExpression => parent?.tryCast(); + // TODO(greg) ancestorDeclaration might be better? FunctionDeclaration? get parentDeclaration => parentExpression?.parentDeclaration; MethodDeclaration? get parentMethod => parent?.tryCast(); @@ -215,52 +366,6 @@ class _ReturnStatementsForBodyVisitor extends RecursiveAstVisitor { } } -/// Uses reflection to determine which value within [values] that [object] represents, -/// and returns the matching value. -/// -/// Currently only works when the fields within [T] only contain core types and not other constant classes. -T getMatchingConst(DartObject object, Iterable values) { - final classMirror = reflectClass(T); - final objectTypeName = object.type!.element!.name; - final valueTypeName = classMirror.simpleName.name; - - if (objectTypeName != valueTypeName) { - throw ArgumentError('Object type $objectTypeName must exactly match value type $valueTypeName'); - } - - final fields = - classMirror.instanceMembers.values.where((m) => m.isGetter && m.isSynthetic).map((m) => m.simpleName).toList(); - - // Find the value where all fields are equal: - return values.singleWhere((value) { - return fields.every((field) { - // Need to use the field symbol and not it converted back from a string or it won't work - // for private members. - final dynamic valueFieldValue = reflect(value).getField(field).reflectee; - final objectFieldValue = object.getField(field.name)!.toWhateverValue(); - return valueFieldValue == objectFieldValue; - }); - }); -} - -extension on DartObject { - Object? toWhateverValue() => - toBoolValue() ?? - toDoubleValue() ?? - toFunctionValue() ?? - toIntValue() ?? - toListValue() ?? - toMapValue() ?? - toSetValue() ?? - toStringValue() ?? - toSymbolValue() ?? - toTypeValue(); -} - -extension on Symbol { - String get name => MirrorSystem.getName(this); -} - extension AstNodeRangeHelper on AstNode { bool containsRangeOf(AstNode other) => other.offset >= offset && other.end <= end; } diff --git a/tools/analyzer_plugin/lib/src/util/constant_instantiation.dart b/tools/analyzer_plugin/lib/src/util/constant_instantiation.dart new file mode 100644 index 000000000..bcb9b4893 --- /dev/null +++ b/tools/analyzer_plugin/lib/src/util/constant_instantiation.dart @@ -0,0 +1,63 @@ +// Copyright 2022 Workiva Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:mirrors'; + +import 'package:analyzer/dart/constant/value.dart'; + +/// Uses reflection to determine which value within [values] that [object] represents, +/// and returns the matching value. +/// +/// Currently only works when the fields within [T] only contain core types and not other constant classes. +T getMatchingConst(DartObject object, Iterable values) { + final classMirror = reflectClass(T); + final objectTypeName = object.type!.element!.name; + final valueTypeName = classMirror.simpleName.name; + + if (objectTypeName != valueTypeName) { + throw ArgumentError('Object type $objectTypeName must exactly match value type $valueTypeName'); + } + + final fields = + classMirror.instanceMembers.values.where((m) => m.isGetter && m.isSynthetic).map((m) => m.simpleName).toList(); + + // Find the value where all fields are equal: + return values.singleWhere((value) { + return fields.every((field) { + // Need to use the field symbol and not it converted back from a string or it won't work + // for private members. + final dynamic valueFieldValue = reflect(value).getField(field).reflectee; + final objectFieldValue = object.getField(field.name)!.toWhateverValue(); + return valueFieldValue == objectFieldValue; + }); + }); +} + +extension on DartObject { + Object? toWhateverValue() => + toBoolValue() ?? + toDoubleValue() ?? + toFunctionValue() ?? + toIntValue() ?? + toListValue() ?? + toMapValue() ?? + toSetValue() ?? + toStringValue() ?? + toSymbolValue() ?? + toTypeValue(); +} + +extension on Symbol { + String get name => MirrorSystem.getName(this); +} diff --git a/tools/analyzer_plugin/lib/src/util/function_components.dart b/tools/analyzer_plugin/lib/src/util/function_components.dart index 51a97e0ab..9fbf400e9 100644 --- a/tools/analyzer_plugin/lib/src/util/function_components.dart +++ b/tools/analyzer_plugin/lib/src/util/function_components.dart @@ -1,5 +1,6 @@ import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer/dart/ast/visitor.dart'; +import 'package:over_react_analyzer_plugin/src/util/hooks.dart'; import 'ast_util.dart'; import 'util.dart'; @@ -56,6 +57,10 @@ FunctionComponent? getClosestFunctionComponent(AstNode node) { FunctionBody? getClosestFunctionComponentBody(AstNode node) => node.ancestors.whereType().firstWhereOrNull(isFunctionComponent); +FunctionBody? getClosestFunctionComponentOrHookBody(AstNode node) => node.ancestors + .whereType() + .firstWhereOrNull((body) => isFunctionComponent(body) || isCustomHookFunction(body)); + /// Returns whether [body] is a React function component body. /// /// This is determined by checking if it's the body of a function passed into one of the various diff --git a/tools/analyzer_plugin/lib/src/util/pretty_print.dart b/tools/analyzer_plugin/lib/src/util/pretty_print.dart new file mode 100644 index 000000000..478a80506 --- /dev/null +++ b/tools/analyzer_plugin/lib/src/util/pretty_print.dart @@ -0,0 +1,53 @@ +// Copyright 2022 Workiva Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Adapted from over_react's prettyPrintMap: https://github.com/Workiva/over_react/blob/aec3dbc2faefd338d45a5ae810305f255095e59b/lib/src/util/pretty_print.dart + +const String nonBreakingSpace = '\u00a0'; +// Use non-breaking spaces so leading spaces show up in IDE tooltips. +const String _indent = '$nonBreakingSpace$nonBreakingSpace'; +const int _maxKeyValuePairsPerLine = 2; +const int _maxListItemsPerLine = 4; +const int _maxSingleLineListOrMapLength = 50; + +/// Indents [str] by [_indent], trimming any trailing whitespace. +String _indentString(String str) { + return str.split('\n').map((line) => (_indent + line).trimRight()).join('\n'); +} + +/// Returns a pretty-printed version of [obj]. Output is similar to that of `toString` for [List]/[Map], +/// but splits longer collections onto multiple lines and indents nested items. +String prettyPrint(Object? obj) { + String prettyPrintCollectionEntries(List entries, int maxEntriesPerLine) { + final singleLineInner = entries.join(', '); + if (entries.length > maxEntriesPerLine || + singleLineInner.contains('\n') || + singleLineInner.length > _maxSingleLineListOrMapLength) { + var inner = _indentString(entries.join(',\n')); + return '\n$inner\n'; + } else { + return '$singleLineInner'; + } + } + + if (obj is List) { + final items = obj.map(prettyPrint).toList(); + return '[${prettyPrintCollectionEntries(items, _maxListItemsPerLine)}]'; + } else if (obj is Map) { + final pairs = obj.keys.map((dynamic key) => '$key: ${prettyPrint(obj[key])}').toList(); + return '{${prettyPrintCollectionEntries(pairs, _maxKeyValuePairsPerLine)}}'; + } else { + return obj.toString(); + } +} diff --git a/tools/analyzer_plugin/lib/src/util/range.dart b/tools/analyzer_plugin/lib/src/util/range.dart new file mode 100644 index 000000000..1e2c28697 --- /dev/null +++ b/tools/analyzer_plugin/lib/src/util/range.dart @@ -0,0 +1,40 @@ +// Copyright 2022 Workiva Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/token.dart'; +import 'package:analyzer/source/source_range.dart'; +import 'package:analyzer_plugin/utilities/range_factory.dart'; + +extension RangeFactoryAddons on RangeFactory { + /// Returns a source range that covers the given [item] (including a leading or + /// trailing comma as appropriate). + /// + /// Like [nodeInList] but doesn't require a reference to the parent node. + SourceRange nodeInList2(AstNode item) { + // Remove the trailing comma. + var nextToken = item.endToken.next; + if (nextToken?.type == TokenType.COMMA) { + return startEnd(item, nextToken!); + } + + // Remove the leading comma. + var prevToken = item.beginToken.previous; + if (prevToken?.type == TokenType.COMMA) { + return startEnd(prevToken!, item); + } + + return node(item); + } +} diff --git a/tools/analyzer_plugin/lib/src/util/react_types.dart b/tools/analyzer_plugin/lib/src/util/react_types.dart index 92aaa1e4a..83ea12981 100644 --- a/tools/analyzer_plugin/lib/src/util/react_types.dart +++ b/tools/analyzer_plugin/lib/src/util/react_types.dart @@ -6,6 +6,9 @@ extension ReactTypes$DartType on DartType { bool get isComponentClass => typeOrBound.element?.isComponentClass ?? false; bool get isReactElement => typeOrBound.element?.isReactElement ?? false; bool get isPropsClass => typeOrBound.element?.isPropsClass ?? false; + bool get isStateHook => typeOrBound.element?.isStateHook ?? false; + bool get isReducerHook => typeOrBound.element?.isReducerHook ?? false; + bool get isTransitionHook => typeOrBound.element?.isTransitionHook ?? false; } extension ReactTypes$Element on Element { @@ -14,6 +17,9 @@ extension ReactTypes$Element on Element { bool get isPropsClass => isOrIsSubtypeOfElementFromPackage('UiProps', 'over_react'); bool get isStateHook => isOrIsSubtypeOfElementFromPackage('StateHook', 'react'); bool get isReducerHook => isOrIsSubtypeOfElementFromPackage('ReducerHook', 'react'); + bool get isTransitionHook => false; + // TODO uncomment one useTransition/TransitionHook is implemented + // bool get isTransitionHook => isOrIsSubtypeOfElementFromPackage('TransitionHook', 'react'); bool isOrIsSubtypeOfElementFromPackage(String typeName, String packageName) { final element = this; diff --git a/tools/analyzer_plugin/lib/src/util/util.dart b/tools/analyzer_plugin/lib/src/util/util.dart index 6a7cafd01..7d26faaa9 100644 --- a/tools/analyzer_plugin/lib/src/util/util.dart +++ b/tools/analyzer_plugin/lib/src/util/util.dart @@ -35,6 +35,11 @@ extension IterableUtil on Iterable { T firstWhereType({T Function()? orElse}) => whereType().firstWhere((_) => true, orElse: orElse); } +extension ListUtil on List { + /// Returns the element at [index], or `null` if the index is greater than the length of this list. + E? elementAtOrNull(int index) => index < length ? this[index] : null; +} + class Tuple2 { final T1 item1; final T2 item2; diff --git a/tools/analyzer_plugin/lib/src/util/weak_map.dart b/tools/analyzer_plugin/lib/src/util/weak_map.dart new file mode 100644 index 000000000..aa396bfb3 --- /dev/null +++ b/tools/analyzer_plugin/lib/src/util/weak_map.dart @@ -0,0 +1,47 @@ +// Copyright 2022 Workiva Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// A wrapper around [Expando] with a more [Map]-like interface, with typing on keys. +/// +/// Keys may not be primitive values, and as a result keys are also non-nullable. +/// +/// Values may also not be nullable in order to simplify the [has]/[putIfAbsent] implementations +/// by assuming that `null` Expando values correspond unambiguously to unset keys. +class WeakMap { + final _valueFor = Expando(); + + V? get(K key) => _valueFor[key]; + + V? getNullableKey(K? key) => key == null ? null : get(key); + + void set(K key, V value) => _valueFor[key] = value; + + bool has(K key) => get(key) != null; + + void remove(K key) => _valueFor[key] = null; + + V putIfAbsent(K key, V Function() ifAbsent) { + final existingValue = get(key); + if (existingValue != null) return existingValue; + final value = ifAbsent(); + set(key, value); + return value; + } +} + +extension MemoizeWithWeakMap on V Function(K) { + V Function(K) memoizeWithWeakMap(WeakMap map) { + return (key) => map.putIfAbsent(key, () => this(key)); + } +} diff --git a/tools/analyzer_plugin/playground/web/exhaustive_deps.dart b/tools/analyzer_plugin/playground/web/exhaustive_deps.dart new file mode 100644 index 000000000..62f96c0c0 --- /dev/null +++ b/tools/analyzer_plugin/playground/web/exhaustive_deps.dart @@ -0,0 +1,132 @@ +import 'dart:html'; + +import 'package:over_react/over_react.dart'; + +part 'exhaustive_deps.over_react.g.dart'; + +mixin FooProps on UiProps {} + +final Foo1 = uiFunction((props) { + final id = props.id; + final count = useState(0); + + final callback = useCallback(() { + print('Count for $id: ${count.value}'); + props.onChange(null); + }, []); +}, null); + +void useSomething(String something) { + useEffect(() { + print(something); + }, []); +} + +final Foo2 = uiFunction((_) { + final count = useState(0); + useEffect(() { + count.set(1); + }, [count]); +}, null); + +final Foo3 = uiFunction((_) { + final count = useState(0); + useEffect(() { + count.set(1 + count.value); + }, []); +}, null); + +final Foo4 = uiFunction((_) { + final ref = useRef(); + useEffect(() { + ref.current; + }, [ref.current]); +}, null); + +final Foo5 = uiFunction((_) { + useEffect(() { + window.console.log('foo'); + }, [window]); +}, null); + +final Foo6 = uiFunction((props) { + final id = props.id; + useEffect(() { + print('I don\'t even use id'); + }, [id]); +}, null); + +void useSomething2() { + final a = {}; + useEffect(() { + a['something'] = 'something else'; + }, [a]); +} + +void useSomething3(String something) { + var a = 1; + useEffect(() { + a = 2; + }, []); +} + +void useSomething4() { + const a = {}; + final b = 1; + useEffect(() { + print(a); + print(b); + }, []); +} + +final Foo7 = uiFunction((props) { + final id = props.id; + useEffect(() { + print('I don\'t even use id'); + }, [id]); +}, null); + +final Foo7 = uiFunction((props) { + final count = useState(0); + foo() { + print('something'); + // count.value; + } + + final callback = useCallback(() { + foo(); + }, []); + + // foo(); +}, null); + +void useSomething5() { + final ref = useRef(); + useEffect(() { + return () { + ref.current; + }; + }, []); +} + +void useSomething6() { + function() {} + useEffect(() {}, [function(), Object(), {}, [], Dom.div()()]); +} + +// TODO(FED-519) these should warn +final Foo333 = uiFunction((_) { + final count = useState(0); + useEffect(() { + count.set(1); + // This will actually cause rerenders since it's a tearoff, so we need to make sure we have an error for this case. + }, [count.set]); +}, null); + +final Foo4444 = uiFunction((_) { + final setCount = useState(0).set; + useEffect(() { + setCount(1); + // This will actually cause rerenders since it's a tearoff, so we need to make sure we have an error for this case. + }, [setCount]); +}, null); diff --git a/tools/analyzer_plugin/pubspec.yaml b/tools/analyzer_plugin/pubspec.yaml index 11f84267a..c4ce8be40 100644 --- a/tools/analyzer_plugin/pubspec.yaml +++ b/tools/analyzer_plugin/pubspec.yaml @@ -23,6 +23,7 @@ dev_dependencies: convert: ^3.0.0 crypto: ^3.0.0 dart_dev: ^3.8.5 + dart_style: ^2.0.0 dependency_validator: ^3.0.0 glob: ^2.0.0 io: ^1.0.0 diff --git a/tools/analyzer_plugin/test/integration/diagnostics/exhaustive_deps_test.dart b/tools/analyzer_plugin/test/integration/diagnostics/exhaustive_deps_test.dart new file mode 100644 index 000000000..7e6b1b37a --- /dev/null +++ b/tools/analyzer_plugin/test/integration/diagnostics/exhaustive_deps_test.dart @@ -0,0 +1,412 @@ +// Disable null-safety in the plugin entrypoint until all dependencies are null-safe, +// otherwise tests won't be able to run. See: https://github.com/dart-lang/test#compiler-flags +//@dart=2.9 + +import 'dart:convert'; + +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer_plugin/protocol/protocol_common.dart'; +import 'package:analyzer_plugin/protocol/protocol_generated.dart'; +import 'package:collection/collection.dart'; +import 'package:dart_style/dart_style.dart'; +import 'package:meta/meta.dart'; +import 'package:over_react_analyzer_plugin/src/util/ast_util.dart'; +import 'package:path/path.dart' as p; +import 'package:over_react_analyzer_plugin/src/diagnostic/exhaustive_deps.dart'; +import 'package:test/test.dart'; +import 'package:test_reflective_loader/test_reflective_loader.dart'; + +import '../../test_util.dart'; +import '../matchers.dart'; +import '../test_bases/diagnostic_test_base.dart'; +import '../test_bases/server_plugin_contributor_test_base.dart'; +import 'exhaustive_deps_test_cases.dart' as test_cases; + +void main() { + group('ExhaustiveDeps', () { + const preamble = r''' +// ignore_for_file: unused_import + +import 'dart:html'; + +import 'package:over_react/over_react.dart'; +import 'package:over_react/over_react.dart' as over_react; + +part 'test.over_react.g.dart'; + +// Implement APIs not defined in MockSdk +dynamic window; +String jsonEncode(Object object) => 'json'; +extension on List { + set length(int newLength) {} +} + +mixin TestProps on UiProps { + var foo; + var bar; + var baz; + var history; + var innerRef; + List items; + num delay; + Function myEffect; + num upperViewHeight; + var local; + var activeTab; + var maybeRef2; + var prop; + Function fn1; + Function fn2; + var hello; + var attribute; + Function function; + Ref someOtherRefs; + var color; + var initY; + Function onPlay; + Function onPause; + bool isEditMode; + Function toggleEditMode; + Function fetchPodcasts; + Function fetchPodcasts2; + var country; + var prop1; + var prop2; + var section_components; + int step; + var increment; + UiFactory Component; +} + +// Globals used by multiple test cases +int setInterval(Function callback, [int duration]) => 0; +void clearInterval(int id) {} +int setTimeout(Function callback, [int duration]) => 0; +void clearTimeout(int id) {} +Function fetch; +void useCustomEffect(Function callback, [List dependencies]) {} +Function debounce(Function callback, num delay) => null; +var global; +dynamic someFunc() => null; + +// Classes used by multiple test cases +abstract class Store { + static Function subscribe(Function listener) => null; +} +abstract class MutableStore { + static dynamic get hello => null; +} +class SomeObject { + final int id; + SomeObject({this.id}); +} +class ObjectWithWritableField { + var field; +} +'''; + + bool errorFilter(AnalysisError error, {@required bool isFromPlugin}) => + defaultErrorFilter(error, isFromPlugin: isFromPlugin) && + // These are intentionally undefined references + !(error.code == 'undefined_identifier' && error.message.contains("Undefined name 'unresolved'.")); + + Future setUpTestBase(TestCase testCase) async { + final additionalHooks = testCase.options + ?.where((option) => option.containsKey('additionalHooks')) + ?.map((option) => option['additionalHooks'] as String) + ?.firstOrNull; + + String analysisYaml; + if (additionalHooks != null) { + // Yaml is a superset of JSON, so we can use it where Yaml is expected. And outputting JSON is easier. :) + // Also, this way, we don't have to worry about escaping strings if we're constructing the yaml ourselves. + analysisYaml = jsonEncode({ + 'over_react': { + 'exhaustive_deps': { + 'additional_hooks': additionalHooks, + }, + }, + }); + } + + final testBase = HooksExhaustiveDepsDiagnosticTest(analysisOptionsYamlContents: analysisYaml); + await testBase.setUp(); + addTearDown(testBase.tearDown); + return testBase; + } + + ({ + 'tests': test_cases.tests, + 'testsFlow': test_cases.testsFlow, + 'testsTypescript': test_cases.testsTypescript, + 'testsTypescriptEslintParserV4': test_cases.testsTypescriptEslintParserV4, + }).forEach((suiteName, suite) { + group('$suiteName:', () { + group('test cases that should pass', () { + suite['valid'].forEachIndexed((i, element) { + final testCase = TestCase.fromJson(element); + test('valid[$i]${testCase.name == null ? '' : ' - ${testCase.name}'}', () async { + // When there are test failures, it's useful to have the original source handy for debugging + // and for searching for the test case object up the test case. + printOnFailure('Test case source (before adding preamble): ```\n${testCase.code}\n```'); + + final testBase = await setUpTestBase(testCase); + final source = testBase.newSource('test.dart', preamble + testCase.code); + await testBase.expectNoErrors(source, errorFilter: errorFilter); + }); + }); + }); + + group('test cases that should warn', () { + suite['invalid'].forEachIndexed((i, element) { + final testCase = TestCase.fromJson(element); + test('invalid[$i]${testCase.name == null ? '' : ' - ${testCase.name}'}', () async { + // When there are test failures, it's useful to have the original source handy for debugging + // and for searching for the test case object up the test case. + printOnFailure('Test case source (before adding preamble): ```\n${testCase.code}\n```'); + + final testBase = await setUpTestBase(testCase); + + final expectedErrors = testCase.errors; + expect(expectedErrors, isNotEmpty); + + final source = testBase.newSource('test.dart', preamble + testCase.code); + final errors = await testBase.getAllErrors(source, includeOtherCodes: true, errorFilter: errorFilter); + expect(errors.dartErrors, isEmpty, + reason: 'Expected there to be no errors coming from the analyzer and not the plugin.' + ' Ensure your test source is free of unintentional errors, such as syntax errors and missing imports.' + ' If errors are expected, set includeOtherErrorCodes:true.'); + expect( + errors.pluginErrors, + everyElement( + AnalysisErrorHavingUtils(isA()).havingCode(ExhaustiveDeps.code.name)), + reason: 'Expected all errors to match the error & fix kinds under test.'); + + /// A mapping of the index of the actual error to the index of te expected error, + /// so we can validate the appropriate fixes for each below. + final expectedErrorIndexByActualErrorIndex = {}; + + { + final expectedMessages = expectedErrors.map((e) => e['message'] as String).toList(); + + // Replace line numbers in messages so we don't have to update them every time the preamble changes. + // Do this instead of just ignoring line numbers in the messages, since that can lead to ambiguities + // between similar errors with different line numbers when mapping their indexes below. + final numPreambleLinesAdded = '\n'.allMatches(preamble).length; + final actualMessages = errors.map((e) { + return e.message.replaceAllMapped(RegExp(r'(at line )(\d+)'), (match) { + final lineNumber = int.parse(match[2]); + return '${match[1]}${lineNumber - numPreambleLinesAdded}'; + }); + }).toList(); + + expect(actualMessages, unorderedEquals(expectedMessages)); + + expectedMessages.forEachIndexed((expectedIndex, expectedMessage) { + final actualIndex = actualMessages.indexOf(expectedMessage); + if (expectedErrorIndexByActualErrorIndex.containsKey(actualIndex)) { + throw StateError( + 'The same expected error message occurs twice, preventing us from mapping them unambiguously.' + ' Please update the test case to not have two of the exact same error messages.' + ' Duplicate message: "$expectedMessage"'); + } + expectedErrorIndexByActualErrorIndex[actualIndex] = expectedIndex; + }); + } + + // Suggestions + // 'suggestions': [ + // { + // 'desc': 'Update the dependencies list to be: [props.foo]', + // 'output': r''' + // final MyComponent = uiFunction((props) { + // useCallback(() { + // print(props.foo?.toString()); + // }, [props.foo]); + // }, null); + // ''', + // }, + for (var i = 0; i < errors.length; i++) { + final actualError = errors.elementAt(i); + Map expectedError; + final expectedErrorIndex = expectedErrorIndexByActualErrorIndex[i]; + try { + expectedError = expectedErrors[expectedErrorIndex]; + } catch (_) { + // The StateError check in the mapping should probably render this catch unreachable, but we'll + // leave this in here just in case. + print('Error mapping actual error at index $i to expected error'); + print('expectedErrorIndex: $expectedErrorIndex'); + print('actualError: $actualError'); + print('expectedErrorIndexByActualErrorIndex: $expectedErrorIndexByActualErrorIndex'); + print('expectedErrors: $expectedErrors'); + print('errors: $errors'); + rethrow; + } + + final expectedFixes = (expectedError['suggestions'] as List ?? []).cast(); + + final actualFixesForError = (await testBase.getAllErrorFixesAtSelection( + SourceSelection(source, actualError.location.offset, actualError.location.length))) + // Some cases have multiple errors on the same selection, each potentially having their own fix. + // Sometimes, the codes are the same, too, so we'll ise the message to disambiguate. + .where((f) => f.error.code == actualError.code && f.error.message == actualError.message) + .toList(); + + if (expectedFixes.isEmpty) { + // Check this before `.hasFix` one so we can see the actual fixes if this `expect` fails. + expect(actualFixesForError, isEmpty, reason: 'was not expecting fixes'); + expect(actualError.hasFix, isFalse, reason: 'was not expecting the error to report it has a fix'); + } else { + String prettyExpectedFixes() => JsonEncoder.withIndent(' ').convert(expectedFixes); + expect(actualError.hasFix, isTrue, + reason: 'error should report it has a fix. Expected fixes: ${prettyExpectedFixes()}'); + expect(actualFixesForError, isNotEmpty, + reason: 'was expecting fixes but got none. Expected fixes: ${prettyExpectedFixes()}'); + expect( + actualFixesForError, + everyElement( + isA().having((f) => f.fixes, 'fixes', [testBase.isAFixUnderTest()]))); + + if (expectedFixes.length > 1 || actualFixesForError.length > 1) { + throw UnimplementedError('Test does not currently support multiple suggestions/fixes'); + } + + final expectedFix = expectedFixes.single; + final expectedFixMessage = expectedFix['desc'] as String; + final expectedOutput = expectedFix['output'] as String; + expect(expectedFixMessage, isNotNull, + reason: 'test setup check: test suggestion \'desc\' should not be null'); + expect(expectedOutput, isNotNull, + reason: 'test setup check: test suggestion \'output\' should not be null'); + + final actualFix = actualFixesForError.single; + expect(actualFix.fixes.map((fix) => fix.change.message).toList(), [expectedFixMessage], + reason: 'fix message should match'); + + final sourceBeforeFixes = source.contents.data; + try { + final fixedSource = testBase.applyErrorFixes(actualFix, source); + + // The source is indented differently, so we'll format before comparing instead to get better + // failure messages if they don't match (equalsIgnoringWhitespace has pretty hard to read messages). + final formatter = DartFormatter(); + String tryFormat(String source, String sourceName) { + try { + return formatter.format(source); + } on FormatterException catch (e) { + fail('Failure formatting source "$sourceName": $e. Source:\n$source'); + } + } + + final expectedOutputWithoutPreamble = tryFormat(expectedOutput, 'expected output'); + final actualOutputWithoutPreamble = + tryFormat(fixedSource.contents.data.replaceFirst(preamble, ''), 'actual output'); + + expect(actualOutputWithoutPreamble, expectedOutputWithoutPreamble, + reason: 'applying fixes should match expected output'); + } finally { + // When fixes are applied, they get written to the source file. + // This means that later iterations in the loop will have unexpected changes, and also their + // fixes won't always end up in the right places since their offsets are stale. + // Revert the changes to the file so that other iterations can test their fixes without interference. + testBase.resourceProvider.updateFile(p.normalize(source.uri.toFilePath()), sourceBeforeFixes); + } + } + } + }); + }); + }); + }); + }); + + group('internal utilities', () { + group( + 'getReactiveHookCallbackIndex (and by extension, getNodeWithoutReactNamespace)' + ' works as expected when the hook being called uses', () { + test('a non-namespaced over_react import', () async { + final unit = (await parseAndGetResolvedUnit(r''' + import 'package:over_react/over_react.dart'; + test() { + useEffect(() {}, []); + } + ''')).unit; + final invocations = + allDescendantsOfType(unit).map((s) => s.expression as InvocationExpression).toList(); + expect(invocations, hasLength(1)); + + expect(invocations.map((i) => getReactiveHookCallbackIndex(i.function)).toList(), [ + isNot(-1), + ]); + }); + + test('a namespaced over_react import', () async { + final unit = (await parseAndGetResolvedUnit(r''' + import 'package:over_react/over_react.dart' as foo; + test() { + foo.useEffect(() {}, []); + // Force this to be a FunctionExpressionInvocation + (foo.useEffect)(() {}, []); + } + ''')).unit; + final invocations = + allDescendantsOfType(unit).map((s) => s.expression as InvocationExpression).toList(); + expect(invocations, hasLength(2)); + + expect(invocations.map((i) => getReactiveHookCallbackIndex(i.function)).toList(), [ + isNot(-1), + isNot(-1), + ]); + }); + + test('an unresolved namespaced import', () async { + final unit = (await parseAndGetResolvedUnit(r''' + test() { + // ignore: undefined_identifier + foo.useEffect(() {}, []); + // Force this to be a FunctionExpressionInvocation + // ignore: undefined_identifier + (foo.useEffect)(() {}, []); + } + ''')).unit; + final invocations = + allDescendantsOfType(unit).map((s) => s.expression as InvocationExpression).toList(); + expect(invocations, hasLength(2)); + + expect(invocations.map((i) => getReactiveHookCallbackIndex(i.function)).toList(), [ + isNot(-1), + isNot(-1), + ]); + }); + }); + }); + }); +} + +class TestCase { + final Map _testCaseJson; + + TestCase.fromJson(this._testCaseJson); + + String get name => _testCaseJson['name'] as String; + + String get code => _testCaseJson['code'] as String; + + List get errors => (_testCaseJson['errors'] as List).cast(); + + List get options => (_testCaseJson['options'] as List)?.cast(); +} + +@reflectiveTest +class HooksExhaustiveDepsDiagnosticTest extends DiagnosticTestBase { + @override + final String analysisOptionsYamlContents; + + HooksExhaustiveDepsDiagnosticTest({this.analysisOptionsYamlContents}); + + @override + get errorUnderTest => ExhaustiveDeps.code; + + @override + get fixKindUnderTest => ExhaustiveDeps.fixKind; +} diff --git a/tools/analyzer_plugin/test/integration/diagnostics/exhaustive_deps_test_cases.dart b/tools/analyzer_plugin/test/integration/diagnostics/exhaustive_deps_test_cases.dart new file mode 100644 index 000000000..f5d653c22 --- /dev/null +++ b/tools/analyzer_plugin/test/integration/diagnostics/exhaustive_deps_test_cases.dart @@ -0,0 +1,8316 @@ +// Adapted from https://github.com/facebook/react/blob/cae635054e17a6f107a39d328649137b83f25972/packages/eslint-plugin-react-hooks/__tests__/ESLintRuleExhaustiveDeps-test.js +// +// MIT License +// +// Copyright (c) Facebook, Inc. and its affiliates. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +// ignore_for_file: implicit_dynamic_map_literal, implicit_dynamic_list_literal + +/// Tests that are valid/invalid across all parsers +final Map>> tests = { + 'valid': [ + { + 'code': r''' + final MyComponent = uiFunction((_) { + final local = {}; + useEffect(() { + print(local); + }); + }, null); + ''', + }, + { + 'code': r''' + final MyComponent = uiFunction((_) { + useEffect(() { + final local = {}; + print(local); + }, []); + }, null); + ''', + }, + { + 'code': r''' + final MyComponent = uiFunction((_) { + final local = someFunc(); + useEffect(() { + print(local); + }, [local]); + }, null); + ''', + }, + { + // OK because '''props''' wasn't defined. + // We don't technically know if '''props''' is supposed + // to be an import that hasn't been added yet, or + // a component-level variable. Ignore it until it + // gets defined (a different rule would flag it anyway). + 'code': r''' + final MyComponent = uiFunction((_) { + useEffect(() { + // ignore: undefined_identifier + print(props.foo); + }, []); + }, null); + ''', + }, + { + 'code': r''' + final MyComponent = uiFunction((_) { + final local1 = {}; + { + final local2 = {}; + useEffect(() { + print(local1); + print(local2); + }); + } + }, null); + ''', + }, + { + 'code': r''' + final MyComponent = uiFunction((_) { + final local1 = someFunc(); + { + final local2 = someFunc(); + useCallback(() { + print(local1); + print(local2); + }, [local1, local2]); + } + }, null); + ''', + }, + { + 'code': r''' + final MyComponent = uiFunction((_) { + final local1 = someFunc(); + final MyNestedComponent = uiFunction((_) { + final local2 = someFunc(); + useCallback(() { + print(local1); + print(local2); + }, [local2]); + }, null); + }, null); + ''', + }, + { + 'code': r''' + final MyComponent = uiFunction((_) { + final local = someFunc(); + useEffect(() { + print(local); + print(local); + }, [local]); + }, null); + ''', + }, + { + 'code': r''' + final MyComponent = uiFunction((_) { + useEffect(() { + print(unresolved); + }, []); + }, null); + ''', + }, + /* (1 case previously here involving holes in arrays (e.g., `[,,'value']`) was removed, since there is no equivalent in Dart) */ + { + // Regression test + 'code': r''' + final MyComponent = uiFunction((props) { + var foo = props.foo; + + useEffect(() { + print(foo.length); + }, [foo]); + }, null); + ''', + }, + { + // Regression test + 'code': r''' + final MyComponent = uiFunction((props) { + var foo = props.foo; + + useEffect(() { + print(foo.length); + print(foo.slice(0)); + }, [foo]); + }, null); + ''', + }, + { + // Regression test + 'code': r''' + final MyComponent = uiFunction((props) { + var history = props.history; + + useEffect(() { + return history.listen(); + }, [history]); + }, null); + ''', + }, + { + // Valid because they have meaning without deps. + 'code': r''' + final MyComponent = uiFunction((props) { + useEffect(() {}); + useLayoutEffect(() {}); + useImperativeHandle(props.innerRef, () {}); + }, null); + ''', + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + useEffect(() { + print(props.foo); + }, [props.foo]); + }, null); + ''', + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + useEffect(() { + print(props.foo); + print(props.bar); + }, [props.bar, props.foo]); + }, null); + ''', + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + useEffect(() { + print(props.foo); + print(props.bar); + }, [props.foo, props.bar]); + }, null); + ''', + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + final local = someFunc(); + useEffect(() { + print(props.foo); + print(props.bar); + print(local); + }, [props.foo, props.bar, local]); + }, null); + ''', + }, + { + // [props, props.foo] is technically unnecessary ('props' covers 'props.foo'). + // However, it's valid for effects to over-specify their deps. + // So we don't warn about this. We *would* warn about useMemo/useCallback. + 'code': r''' + final MyComponent = uiFunction((props) { + final local = {}; + useEffect(() { + print(props.foo); + print(props.bar); + }, [props, props.foo]); + var color = someFunc(); + useEffect(() { + print(props.foo.bar.baz); + print(color); + }, [props.foo, props.foo.bar.baz, color]); + }, null); + ''', + }, + // Nullish coalescing and optional chaining + { + 'code': r''' + final MyComponent = uiFunction((props) { + useEffect(() { + print(props.foo?.bar?.baz ?? null); + }, [props.foo]); + }, null); + ''', + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + useEffect(() { + print(props.foo?.bar); + }, [props.foo?.bar]); + }, null); + ''', + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + useEffect(() { + print(props.foo?.bar); + }, [props.foo.bar]); + }, null); + ''', + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + useEffect(() { + print(props.foo.bar); + }, [props.foo?.bar]); + }, null); + ''', + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + useEffect(() { + print(props.foo.bar); + print(props.foo?.bar); + }, [props.foo?.bar]); + }, null); + ''', + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + useEffect(() { + print(props.foo.bar); + print(props.foo?.bar); + }, [props.foo.bar]); + }, null); + ''', + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + useEffect(() { + print(props.foo); + print(props.foo?.bar); + }, [props.foo]); + }, null); + ''', + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + useEffect(() { + print(props.foo?.toString()); + }, [props.foo]); + }, null); + ''', + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + useMemo(() { + print(props.foo?.toString()); + }, [props.foo]); + }, null); + ''', + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + useCallback(() { + print(props.foo?.toString()); + }, [props.foo]); + }, null); + ''', + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + useCallback(() { + print(props.foo.bar?.toString()); + }, [props.foo.bar]); + }, null); + ''', + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + useCallback(() { + print(props.foo?.bar?.toString()); + }, [props.foo.bar]); + }, null); + ''', + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + useCallback(() { + print(props.foo.bar.toString()); + }, [props?.foo?.bar]); + }, null); + ''', + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + useCallback(() { + print(props.foo?.bar?.baz); + }, [props?.foo.bar?.baz]); + }, null); + ''', + }, + { + 'code': r''' + final MyComponent = uiFunction((_) { + final myEffect = () { + // Doesn't use anything + }; + useEffect(myEffect, []); + }, null); + ''', + }, + { + 'code': r''' + final local = {}; + final MyComponent = uiFunction((_) { + final myEffect = () { + print(local); + }; + useEffect(myEffect, []); + }, null); + ''', + }, + { + 'code': r''' + final local = {}; + final MyComponent = uiFunction((_) { + myEffect() { + print(local); + } + useEffect(myEffect, []); + }, null); + ''', + }, + { + 'code': r''' + final MyComponent = uiFunction((_) { + final local = someFunc(); + myEffect() { + print(local); + } + useEffect(myEffect, [local]); + }, null); + ''', + }, + { + 'code': r''' + final MyComponent = uiFunction((_) { + myEffect() { + print(global); + } + useEffect(myEffect, []); + }, null); + ''', + }, + { + 'code': r''' + final local = {}; + final MyComponent = uiFunction((_) { + final otherThing = () { + print(local); + }; + final myEffect = () { + otherThing(); + }; + useEffect(myEffect, []); + }, null); + ''', + }, + { + // Valid because even though we don't inspect the function itself, + // at least it's passed as a dependency. + 'code': r''' + final MyComponent = uiFunction((props) { + var delay = props.delay; + + final local = {}; + final myEffect = debounce(() { + print(local); + }, delay); + useEffect(myEffect, [myEffect]); + }, null); + ''', + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + var myEffect = props.myEffect; + + useEffect(myEffect, [null, myEffect]); + }, null); + ''', + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + var myEffect = props.myEffect; + + useEffect(myEffect, [null, myEffect, null, null]); + }, null); + ''', + }, + { + 'code': r''' + var local = {}; + myEffect() { + print(local); + } + final MyComponent = uiFunction((_) { + useEffect(myEffect, []); + }, null); + ''', + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + var myEffect = props.myEffect; + + useEffect(myEffect, [myEffect]); + }, null); + ''', + }, + { + // Valid because has no deps. + 'code': r''' + final MyComponent = uiFunction((props) { + var myEffect = props.myEffect; + + useEffect(myEffect); + }, null); + ''', + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + useCustomEffect(() { + print(props.foo); + }); + }, null); + ''', + 'options': [ + {'additionalHooks': 'useCustomEffect'} + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + useCustomEffect(() { + print(props.foo); + }, [props.foo]); + }, null); + ''', + 'options': [ + {'additionalHooks': 'useCustomEffect'} + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + useCustomEffect(() { + print(props.foo); + }, []); + }, null); + ''', + 'options': [ + {'additionalHooks': 'useAnotherEffect'} + ], + }, + { + 'code': r''' + void useWithoutEffectSuffix(Function callback, [List dependencies]) {} + final MyComponent = uiFunction((props) { + useWithoutEffectSuffix(() { + print(props.foo); + }, []); + }, null); + ''', + }, + { + 'code': r''' + dynamic renderHelperConfusedWithEffect(Function callback, dynamic secondArg) => null; + final MyComponent = uiFunction((props) { + return renderHelperConfusedWithEffect(() { + print(props.foo); + }, []); + }, null); + ''', + }, + { + // Valid because we don't care about hooks outside of components. + 'code': r''' + nonHookFunction() { + final local = {}; + // ignore: over_react_rules_of_hooks + useEffect(() { + print(local); + }, []); + } + ''', + }, + { + // Valid because we don't care about hooks outside of components. + 'code': r''' + nonHookFunction() { + final local1 = {}; + { + final local2 = {}; + // ignore: over_react_rules_of_hooks + useEffect(() { + print(local1); + print(local2); + }, []); + } + } + ''', + }, + { + 'code': r''' + final MyComponent = uiFunction((_) { + final ref = useRef(); + useEffect(() { + print(ref.current); + }, [ref]); + }, null); + ''', + }, + { + 'name': 'Ref from useRef', + 'code': r''' + final MyComponent = uiFunction((_) { + final ref = useRef(); + useEffect(() { + print(ref.current); + }, []); + }, null); + ''', + }, + { + 'name': 'Ref from useRef (namespaced)', + 'code': r''' + final MyComponent = uiFunction((_) { + final ref = over_react.useRef(); + useEffect(() { + print(ref.current); + }, []); + }, null); + ''', + }, + { + 'code': r''' + StateHook useFunnyState(T initialState) {} + ReducerHook useFunnyReducer(dynamic reducer, T initialState) {} + dynamic useSomeOtherRefyThing() => null; + final MyComponent = uiFunction((props) { + var maybeRef2 = props.maybeRef2; + var foo = props.foo; + + final definitelyRef1 = useRef(); + final definitelyRef2 = useRef(); + final maybeRef1 = useSomeOtherRefyThing(); + var state1 = useState(null); + var state2 = over_react.useState(null); + var state3 = useReducer(null, null); + var state4 = over_react.useReducer(null, null); + var state5 = useFunnyState(null); + var state6 = useFunnyReducer(null, null); + // TODO uncomment once useTransition is implemented. + // const [isPending1] = useTransition(); + // var isPending2 = useTransition(); + // const [isPending3] = over_react.useTransition(); + // var isPending4 = over_react.useTransition(); + final mySetState = useCallback(() {}, []); + var myDispatch = useCallback(() {}, []); + useEffect(() { + // Known to be static + print(definitelyRef1.current); + print(definitelyRef2.current); + print(maybeRef1.current); + print(maybeRef2.current); + state1.set(null); + state2.set(null); + state3.dispatch(null); + state4.dispatch(null); + // startTransition1(); + // isPending2.set(null); + // startTransition3(); + // isPending4.set(null); + // Dynamic + print(state1.value); + print(state2.value); + print(state3.state); + print(state4.state); + print(state5.value); + print(state6.state); + // print(isPending2.value); + // print(isPending4.value); + mySetState(); + myDispatch(); + // Not sure; assume dynamic + state5.set(null); + state6.dispatch(null); + }, [ + // Dynamic + state1.value, state2.value, state3.state, state4.state, state5.value, state6.state, + maybeRef1, maybeRef2, + // isPending2.value, isPending4.value, + // Not sure; assume dynamic + mySetState, myDispatch, + state5.set, state6.dispatch + // In this test, we don't specify static deps. + // That should be okay. + ]); + }, null); + ''', + }, + { + 'code': r''' + StateHook useFunnyState(T initialState) {} + ReducerHook useFunnyReducer(dynamic reducer, T initialState) {} + dynamic useSomeOtherRefyThing() => null; + final MyComponent = uiFunction((props) { + var maybeRef2 = props.maybeRef2; + + final definitelyRef1 = useRef(); + final definitelyRef2 = useRef(); + final maybeRef1 = useSomeOtherRefyThing(); + var state1 = useState(null); + var state2 = over_react.useState(null); + var state3 = useReducer(null, null); + var state4 = over_react.useReducer(null, null); + var state5 = useFunnyState(null); + var state6 = useFunnyReducer(null, null); + final mySetState = useCallback(() {}, []); + var myDispatch = useCallback(() {}, []); + useEffect(() { + // Known to be static + print(definitelyRef1.current); + print(definitelyRef2.current); + print(maybeRef1.current); + print(maybeRef2.current); + state1.set(null); + state2.set(null); + state3.dispatch(null); + state4.dispatch(null); + // Dynamic + print(state1.value); + print(state2.value); + print(state3.state); + print(state4.state); + print(state5.value); + print(state6.state); + mySetState(); + myDispatch(); + // Not sure; assume dynamic + state5.set(null); + state6.dispatch(null); + }, [ + // Dynamic + state1.value, state2.value, state3.state, state4.state, state5.value, state6.state, + maybeRef1, maybeRef2, + // Not sure; assume dynamic + mySetState, myDispatch, + state5.set, state6.dispatch, + // In this test, we specify static deps. + // That should be okay too! + definitelyRef1, definitelyRef2, state1.set, state2.set, state3.dispatch, state4.dispatch + ]); + }, null); + ''', + }, + { + 'code': r''' + final MyComponent = uiForwardRef((props, ref) { + useImperativeHandle(ref, () => ({ + 'focus': () { + window.alert(props.hello); + } + })); + }, null); + ''', + }, + { + 'code': r''' + final MyComponent = uiForwardRef((props, ref) { + useImperativeHandle(ref, () => ({ + 'focus': () { + window.alert(props.hello); + } + }), [props.hello]); + }, null); + ''', + }, + { + // This is not ideal but warning would likely create + // too many false positives. We do, however, prevent + // direct assignments. + 'code': r''' + final MyComponent = uiFunction((props) { + var obj = someFunc(); + useEffect(() { + obj.foo = true; + }, [obj]); + }, null); + ''', + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + dynamic foo; + useEffect(() { + foo.bar.baz = 43; + }, [foo.bar]); + }, null); + ''', + }, + { + // Valid because we assign ref.current + // ourselves. Therefore it's likely not + // a ref managed by React. + 'code': r''' + final MyComponent = uiFunction((_) { + final myRef = useRef(); + useEffect(() { + final handleMove = () {}; + myRef.current = {}; + return () { + print(myRef.current.toString()); + }; + }, []); + return Dom.div()(); + }, null); + ''', + }, + { + // Valid because we assign ref.current + // ourselves. Therefore it's likely not + // a ref managed by React. + 'code': r''' + final MyComponent = uiFunction((_) { + final myRef = useRef(); + useEffect(() { + final handleMove = () {}; + myRef.current = {}; + return () { + print(myRef?.current?.toString()); + }; + }, []); + return Dom.div()(); + }, null); + ''', + }, + { + // Valid because we assign ref.current + // ourselves. Therefore it's likely not + // a ref managed by React. + 'code': r''' + useMyThing(myRef) { + useEffect(() { + final handleMove = () {}; + myRef.current = {}; + return () { + print(myRef.current.toString()); + }; + }, [myRef]); + } + ''', + }, + { + // Valid because the ref is captured. + 'code': r''' + final MyComponent = uiFunction((_) { + final myRef = useRef(); + useEffect(() { + final handleMove = () {}; + final node = myRef.current; + node.addEventListener('mousemove', handleMove); + return () => node.removeEventListener('mousemove', handleMove); + }, []); + return (Dom.div()..ref = myRef)(); + }, null); + ''', + }, + { + // Valid because the ref is captured. + 'code': r''' + useMyThing(myRef) { + useEffect(() { + final handleMove = () {}; + final node = myRef.current; + node.addEventListener('mousemove', handleMove); + return () => node.removeEventListener('mousemove', handleMove); + }, [myRef]); + return (Dom.div()..ref = myRef)(); + } + ''', + }, + { + // Valid because it's not an effect. + 'code': r''' + useMyThing(myRef) { + useCallback(() { + final handleMouse = () {}; + myRef.current.addEventListener('mousemove', handleMouse); + myRef.current.addEventListener('mousein', handleMouse); + return () { + setTimeout(() { + myRef.current.removeEventListener('mousemove', handleMouse); + myRef.current.removeEventListener('mousein', handleMouse); + }); + }; + }, [myRef]); + } + ''', + }, + { + // Valid because we read ref.current in a function that isn't cleanup. + 'code': r''' + useMyThing() { + final myRef = useRef(); + useEffect(() { + final handleMove = () { + print(myRef.current); + }; + window.addEventListener('mousemove', handleMove); + return () => window.removeEventListener('mousemove', handleMove); + }, []); + return (Dom.div()..ref = myRef)(); + } + ''', + }, + { + // Valid because we read ref.current in a function that isn't cleanup. + 'code': r''' + useMyThing() { + final myRef = useRef(); + useEffect(() { + Function handleMove; + handleMove = () { + return () => window.removeEventListener('mousemove', handleMove); + }; + window.addEventListener('mousemove', handleMove); + return () {}; + }, []); + return (Dom.div()..ref = myRef)(); + } + ''', + }, + { + // Valid because it's a primitive constant. + 'code': r''' + final MyComponent = uiFunction((_) { + final local1 = 42; + final local2 = '42'; + final local3 = null; + useEffect(() { + print(local1); + print(local2); + print(local3); + }, []); + }, null); + ''', + }, + { + // It's not a mistake to specify constant values though. + 'code': r''' + final MyComponent = uiFunction((_) { + final local1 = 42; + final local2 = '42'; + final local3 = null; + useEffect(() { + print(local1); + print(local2); + print(local3); + }, [local1, local2, local3]); + }, null); + ''', + }, + { + // It is valid for effects to over-specify their deps. + 'code': r''' + final MyComponent = uiFunction((props) { + final local = props.local; + useEffect(() {}, [local]); + }, null); + ''', + }, + { + // Valid even though activeTab is "unused". + // We allow over-specifying deps for effects, but not callbacks or memo. + 'code': r''' + final Foo = uiFunction((props) { + var activeTab = props.activeTab; + + useEffect(() { + window.scrollTo(0, 0); + }, [activeTab]); + }, null); + ''', + }, + { + // It is valid to specify broader effect deps than strictly necessary. + // Don't warn for this. + 'code': r''' + final MyComponent = uiFunction((props) { + useEffect(() { + print(props.foo.bar.baz); + }, [props]); + useEffect(() { + print(props.foo.bar.baz); + }, [props.foo]); + useEffect(() { + print(props.foo.bar.baz); + }, [props.foo.bar]); + useEffect(() { + print(props.foo.bar.baz); + }, [props.foo.bar.baz]); + }, null); + ''', + }, + { + // It is *also* valid to specify broader memo/callback deps than strictly necessary. + // Don't warn for this either. + 'code': r''' + final MyComponent = uiFunction((props) { + final fn = useCallback(() { + print(props.foo.bar.baz); + }, [props]); + final fn2 = useCallback(() { + print(props.foo.bar.baz); + }, [props.foo]); + final fn3 = useMemo(() { + print(props.foo.bar.baz); + }, [props.foo.bar]); + final fn4 = useMemo(() { + print(props.foo.bar.baz); + }, [props.foo.bar.baz]); + }, null); + ''', + }, + { + // Declaring handleNext is optional because + // it doesn't use anything in the function scope. + 'code': r''' + final MyComponent = uiFunction((props) { + handleNext1() { + print('hello'); + } + final handleNext2 = () { + print('hello'); + }; + var handleNext3 = () { + print('hello'); + }; + useEffect(() { + return Store.subscribe(handleNext1); + }, []); + useLayoutEffect(() { + return Store.subscribe(handleNext2); + }, []); + useMemo(() { + return Store.subscribe(handleNext3); + }, []); + }, null); + ''', + }, + { + // Declaring handleNext is optional because + // it doesn't use anything in the function scope. + 'code': r''' + final MyComponent = uiFunction((props) { + handleNext() { + print('hello'); + } + useEffect(() { + return Store.subscribe(handleNext); + }, []); + useLayoutEffect(() { + return Store.subscribe(handleNext); + }, []); + useMemo(() { + return Store.subscribe(handleNext); + }, []); + }, null); + ''', + }, + { + // Declaring handleNext is optional because + // everything they use is fully static. + 'code': r''' + Function foo; + final MyComponent = uiFunction((props) { + var state = useState(null); + var dispatch = over_react.useReducer(null, null).dispatch; + handleNext1(value) { + var value2 = value * 100; + state.set(value2); + print('hello'); + } + final handleNext2 = (value) { + state.set(foo(value)); + print('hello'); + }; + var handleNext3 = (value) { + print(value); + dispatch({ 'type': 'x', 'value': value }); + }; + useEffect(() { + return Store.subscribe(handleNext1); + }, []); + useLayoutEffect(() { + return Store.subscribe(handleNext2); + }, []); + useMemo(() { + return Store.subscribe(handleNext3); + }, []); + }, null); + ''', + }, + { + 'code': r''' + useInterval(callback, delay) { + final savedCallback = useRef(); + useEffect(() { + savedCallback.current = callback; + }); + useEffect(() { + tick() { + savedCallback.current(); + } + if (delay != null) { + var id = setInterval(tick, delay); + return () => clearInterval(id); + } + }, [delay]); + } + ''', + }, + { + 'code': r''' + final Counter = uiFunction((_) { + var count = useState(0); + useEffect(() { + var id = setInterval(() { + count.setWithUpdater((c) => c + 1); + }, 1000); + return () => clearInterval(id); + }, []); + return Dom.h1()(count.value); + }, null); + ''', + }, + { + 'code': r''' + final Counter = uiFunction((_) { + var count = useState(0); + tick() { + count.setWithUpdater((c) => c + 1); + } + useEffect(() { + var id = setInterval(() { + tick(); + }, 1000); + return () => clearInterval(id); + }, []); + return Dom.h1()(count.value); + }, null); + ''', + }, + { + 'code': r''' + final Counter = uiFunction((_) { + var count = useReducer((state, action) { + if (action == 'inc') { + return state + 1; + } + }, 0); + useEffect(() { + var id = setInterval(() { + count.dispatch('inc'); + }, 1000); + return () => clearInterval(id); + }, []); + return Dom.h1()(count.state); + }, null); + ''', + }, + { + 'code': r''' + final Counter = uiFunction((_) { + var count = useReducer((state, action) { + if (action == 'inc') { + return state + 1; + } + }, 0); + final tick = () { + count.dispatch('inc'); + }; + useEffect(() { + var id = setInterval(tick, 1000); + return () => clearInterval(id); + }, []); + return Dom.h1()(count.state); + }, null); + ''', + }, + /* ("Regression test for a crash" previously here was removed since the original cause of the crash is not applicable + in the Dart logic, and the test case involving variables declared after they're referenced is not valid in Dart.) */ + { + 'code': r''' + withFetch(fetchPodcasts) { + return uiFunction((props) { + final id = props.id; + var podcasts = useState(null); + useEffect(() { + fetchPodcasts(id).then(podcasts.set); + }, [id]); + }, null); + } + ''', + }, + { + 'code': r''' + abstract class API { + static dynamic fetchPodcasts() => null; + } + final Podcasts = uiFunction((props) { + var id = props.id; + + var podcasts = useState(null); + useEffect(() { + doFetch({ fetchPodcasts }) { + fetchPodcasts(id).then(podcasts.set); + } + doFetch(fetchPodcasts: API.fetchPodcasts); + }, [id]); + }, null); + ''', + }, + { + 'code': r''' + final Counter = uiFunction((_) { + var count = useState(0); + int increment(int x) { + return x + 1; + } + useEffect(() { + var id = setInterval(() { + count.setWithUpdater(increment); + }, 1000); + return () => clearInterval(id); + }, []); + return Dom.h1()(count.value); + }, null); + ''', + }, + { + 'code': r''' + final Counter = uiFunction((_) { + var count = useState(0); + int increment(int x) { + return x + 1; + } + useEffect(() { + var id = setInterval(() { + count.setWithUpdater((value) => increment(value)); + }, 1000); + return () => clearInterval(id); + }, []); + return Dom.h1()(count.value); + }, null); + ''', + }, + { + 'code': r''' + var globalIncrementValue; + final Counter = uiFunction((_) { + var count = useState(0); + useEffect(() { + var id = setInterval(() { + count.setWithUpdater((value) => value + globalIncrementValue); + }, 1000); + return () => clearInterval(id); + }, []); + return Dom.h1()(count.value); + }, null); + ''', + }, + { + 'code': r''' + withStuff(increment) { + return uiFunction((_) { + var count = useState(0); + useEffect(() { + var id = setInterval(() { + count.setWithUpdater((value) => value + increment); + }, 1000); + return () => clearInterval(id); + }, []); + return Dom.h1()(count.value); + }, null); + } + ''', + }, + { + 'code': r''' + final App = uiFunction((_) { + var query = useState('react'); + var state = useState(null); + useEffect(() { + var ignore = false; + fetchSomething() async { + final result = await (await fetch('http://hn.algolia.com/api/v1/search?query.value=' + query.value)).json(); + if (!ignore) state.set(result); + } + fetchSomething(); + return () { ignore = true; }; + }, [query.value]); + return ( + Fragment()( + (Dom.input()..value = query.value ..onChange=(e) => query.set(e.target.value))(), + jsonEncode(state.value) + ) + ); + }, null); + ''', + }, + /* (2 cases previously here involving referencing functions inside their declarations were removed, since that's not valid in Dart) */ + { + 'code': r''' + final Hello = uiFunction((_) { + var state = useState(0); + useEffect(() { + final handleResize = () => state.set(window.innerWidth); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }); + }, null); + ''', + }, + /* (2 cases previously here involving `arguments` keyword were removed) */ + // Regression test. + { + 'code': r''' + final Example = uiFunction((props) { + useEffect(() { + var topHeight = 0; + topHeight = props.upperViewHeight; + }, [props.upperViewHeight]); + }, null); + ''', + }, + // Regression test. + { + 'code': r''' + final Example = uiFunction((props) { + useEffect(() { + var topHeight = 0; + topHeight = props?.upperViewHeight; + }, [props?.upperViewHeight]); + }, null); + ''', + }, + // Regression test. + { + 'code': r''' + final Example = uiFunction((props) { + useEffect(() { + var topHeight = 0; + topHeight = props?.upperViewHeight; + }, [props]); + }, null); + ''', + }, + { + 'code': r''' + useFoo(foo){ + return useMemo(() => foo, [foo]); + } + ''', + }, + { + 'code': r''' + useFoo(){ + final foo = "hi!"; + return useMemo(() => foo, [foo]); + } + ''', + }, + /* (Cases previously here involving destructuring keyword were removed) */ + { + 'code': r''' + useFoo() { + final foo = "fine"; + if (true) { + // Shadowed variable with constant construction in a nested scope is fine. + dynamic foo; + } + return useMemo(() => foo, [foo]); + } + ''', + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + var foo = props.foo; + + return useMemo(() => foo, [foo]); + }, null); + ''', + }, + { + 'code': r''' + final MyComponent = uiFunction((_) { + final foo = true ? "fine" : "also fine"; + return useMemo(() => foo, [foo]); + }, null); + ''', + }, + // This previously-invalid test case is valid for the Dart implementation. + { + 'code': r''' + final MyComponent = uiFunction((props) { + final skillsCount = useState(null); + useEffect(() { + if (skillsCount.value == 0 && !props.isEditMode) { + props.toggleEditMode(); + } + }, [skillsCount.value, props.isEditMode, props.toggleEditMode]); + }, null); + ''', + }, + { + 'name': 'Calling function props in a cascade with props as dependency', + 'code': r''' + final MyComponent = uiFunction((props) { + useEffect(() { + props + ..onClick(null) + ..onChange(null); + }, [props]); + }, null); + ''', + }, + { + 'code': r''' + final MyComponent = uiFunction((_) { + final _button = useMemo(() => Dom.button()('Click me'), []); + final _controls = useMemo(() { + return Dom.div()(_button); + }, [_button]); + }, null); + ''' + }, + { + 'name': 'Generic type parameter referenced inside callback from outer scope', + 'code': r''' + UiFactory createComponent() { + return uiFunction((_) { + useEffect(() { + final items = []; + }, []); + }, null); + } + ''', + }, + { + 'name': 'Dependency with cascade in initializer', + 'code': r''' + final MyComponent = uiFunction((props) { + final items = props.items..forEach((_) {}); + useEffect(() { + print(items); + }, [items]); + }, null); + ''', + }, + { + 'name': 'Cascade on dependency inside callback', + 'code': r''' + final MyComponent = uiFunction((props) { + final items = props.items; + useEffect(() { + print(items..forEach((_) {})); + }, [items]); + }, null); + ''', + }, + { + 'name': 'Cascaded assignment on dependency inside callback', + 'code': r''' + final MyComponent = uiFunction((props) { + final object = useMemo(() => ObjectWithWritableField(), []); + useEffect(() { + object..field = 'something'; + }, [object]); + }, null); + ''', + }, + ], + 'invalid': [ + { + 'code': r''' + final MyComponent = uiFunction((props) { + useCallback(() { + print(props.foo?.toString()); + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useCallback has a missing dependency: \'props.foo\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [props.foo]', + 'output': r''' + final MyComponent = uiFunction((props) { + useCallback(() { + print(props.foo?.toString()); + }, [props.foo]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + useCallback(() { + print(props.foo?.bar.baz); + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useCallback has a missing dependency: \'props.foo?.bar.baz\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [props.foo?.bar.baz]', + 'output': r''' + final MyComponent = uiFunction((props) { + useCallback(() { + print(props.foo?.bar.baz); + }, [props.foo?.bar.baz]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + useCallback(() { + print(props.foo?.bar?.baz); + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useCallback has a missing dependency: \'props.foo?.bar?.baz\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [props.foo?.bar?.baz]', + 'output': r''' + final MyComponent = uiFunction((props) { + useCallback(() { + print(props.foo?.bar?.baz); + }, [props.foo?.bar?.baz]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + useCallback(() { + print(props.foo?.bar.toString()); + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useCallback has a missing dependency: \'props.foo?.bar\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [props.foo?.bar]', + 'output': r''' + final MyComponent = uiFunction((props) { + useCallback(() { + print(props.foo?.bar.toString()); + }, [props.foo?.bar]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((_) { + final local = someFunc(); + useEffect(() { + print(local); + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'local\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [local]', + 'output': r''' + final MyComponent = uiFunction((_) { + final local = someFunc(); + useEffect(() { + print(local); + }, [local]); + }, null); + ''', + }, + ], + }, + ], + }, + { + // Note: we *could* detect it's a primitive and never assigned + // even though it's not a constant -- but we currently don't. + // So this is an error. + 'code': r''' + final MyComponent = uiFunction((_) { + var local = 42; + useEffect(() { + print(local); + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'local\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [local]', + 'output': r''' + final MyComponent = uiFunction((_) { + var local = 42; + useEffect(() { + print(local); + }, [local]); + }, null); + ''', + }, + ], + }, + ], + }, + /* (1 cases previously here involving regex literals was removed) */ + { + // Invalid because they don't have a meaning without deps. + 'code': r''' + final MyComponent = uiFunction((props) { + final value = useMemo(() { return 2*2; }); + // In JS, dependencies are optional for both functions. In Dart, they're required for only useCallback (this is likely an oversight). + // ignore: not_enough_positional_arguments + final fn = useCallback(() { window.alert('foo'); }); + }, null); + ''', + // We don't know what you meant. + 'errors': [ + { + 'message': + 'React Hook useMemo does nothing when called with only one argument. Did you forget to pass a list of dependencies?', + 'suggestions': null, + }, + { + 'message': + 'React Hook useCallback does nothing when called with only one argument. Did you forget to pass a list of dependencies?', + 'suggestions': null, + }, + ], + }, + { + // Invalid because they don't have a meaning without deps. + 'code': r''' + final MyComponent = uiFunction((props) { + var fn1 = props.fn1; + var fn2 = props.fn2; + + final value = useMemo(fn1); + // In JS, dependencies are optional for both functions. In Dart, they're required for only useCallback (this is likely an oversight). + // ignore: not_enough_positional_arguments + final fn = useCallback(fn2); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useMemo does nothing when called with only one argument. Did you forget to pass a list of dependencies?', + 'suggestions': null, + }, + { + 'message': + 'React Hook useCallback does nothing when called with only one argument. Did you forget to pass a list of dependencies?', + 'suggestions': null, + }, + ], + }, + /* (Cases previously here involving missing effect callback arguments were removed) */ + { + // Regression test + 'code': r''' + final MyComponent = uiFunction((_) { + final local = someFunc(); + useEffect(() { + if (true) { + print(local); + } + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'local\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [local]', + 'output': r''' + final MyComponent = uiFunction((_) { + final local = someFunc(); + useEffect(() { + if (true) { + print(local); + } + }, [local]); + }, null); + ''', + }, + ], + }, + ], + }, + { + // Regression test + 'code': r''' + final MyComponent = uiFunction((_) { + final local = {}; + useEffect(() { + try { + print(local); + } finally {} + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'local\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [local]', + 'output': r''' + final MyComponent = uiFunction((_) { + final local = {}; + useEffect(() { + try { + print(local); + } finally {} + }, [local]); + }, null); + ''', + }, + ], + }, + ], + }, + { + // Regression test + 'code': r''' + final MyComponent = uiFunction((_) { + final local = {}; + useEffect(() { + inner() { + print(local); + } + inner(); + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'local\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [local]', + 'output': r''' + final MyComponent = uiFunction((_) { + final local = {}; + useEffect(() { + inner() { + print(local); + } + inner(); + }, [local]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((_) { + final local1 = someFunc(); + { + final local2 = someFunc(); + useEffect(() { + print(local1); + print(local2); + }, []); + } + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has missing dependencies: \'local1\' and \'local2\'. Either include them or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [local1, local2]', + 'output': r''' + final MyComponent = uiFunction((_) { + final local1 = someFunc(); + { + final local2 = someFunc(); + useEffect(() { + print(local1); + print(local2); + }, [local1, local2]); + } + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((_) { + final local1 = {}; + final local2 = {}; + useEffect(() { + print(local1); + print(local2); + }, [local1]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'local2\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [local1, local2]', + 'output': r''' + final MyComponent = uiFunction((_) { + final local1 = {}; + final local2 = {}; + useEffect(() { + print(local1); + print(local2); + }, [local1, local2]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((_) { + final local1 = {}; + final local2 = {}; + useMemo(() { + print(local1); + }, [local1, local2]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useMemo has an unnecessary dependency: \'local2\'. Either exclude it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [local1]', + 'output': r''' + final MyComponent = uiFunction((_) { + final local1 = {}; + final local2 = {}; + useMemo(() { + print(local1); + }, [local1]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((_) { + final local1 = someFunc(); + final MyNestedComponent = uiFunction((_) { + final local2 = {}; + useCallback(() { + print(local1); + print(local2); + }, [local1]); + }, null); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useCallback has a missing dependency: \'local2\'. Either include it or remove the dependency list. Outer scope values like \'local1\' aren\'t valid dependencies because mutating them doesn\'t re-render the component.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [local2]', + 'output': r''' + final MyComponent = uiFunction((_) { + final local1 = someFunc(); + final MyNestedComponent = uiFunction((_) { + final local2 = {}; + useCallback(() { + print(local1); + print(local2); + }, [local2]); + }, null); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((_) { + final local = {}; + useEffect(() { + print(local); + print(local); + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'local\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [local]', + 'output': r''' + final MyComponent = uiFunction((_) { + final local = {}; + useEffect(() { + print(local); + print(local); + }, [local]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((_) { + final local = {}; + useEffect(() { + print(local); + print(local); + }, [local, local]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a duplicate dependency: \'local\'. Either omit it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [local]', + 'output': r''' + final MyComponent = uiFunction((_) { + final local = {}; + useEffect(() { + print(local); + print(local); + }, [local]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((_) { + useCallback(() {}, [window]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useCallback has an unnecessary dependency: \'window\'. Either exclude it or remove the dependency list. Outer scope values like \'window\' aren\'t valid dependencies because mutating them doesn\'t re-render the component.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: []', + 'output': r''' + final MyComponent = uiFunction((_) { + useCallback(() {}, []); + }, null); + ''', + }, + ], + }, + ], + }, + { + // It is not valid for useCallback to specify extraneous deps + // because it doesn't serve as a side effect trigger unlike useEffect. + 'code': r''' + final MyComponent = uiFunction((props) { + var local = props.foo; + useCallback(() {}, [local]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useCallback has an unnecessary dependency: \'local\'. Either exclude it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: []', + 'output': r''' + final MyComponent = uiFunction((props) { + var local = props.foo; + useCallback(() {}, []); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + var history = props.history; + + useEffect(() { + return history.listen(); + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'history\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [history]', + 'output': r''' + final MyComponent = uiFunction((props) { + var history = props.history; + + useEffect(() { + return history.listen(); + }, [history]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + var history = props.history; + + useEffect(() { + return [ + history.foo.bar[2].dobedo.listen(), + history.foo.bar().dobedo.listen[2] + ]; + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'history.foo\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [history.foo]', + 'output': r''' + final MyComponent = uiFunction((props) { + var history = props.history; + + useEffect(() { + return [ + history.foo.bar[2].dobedo.listen(), + history.foo.bar().dobedo.listen[2] + ]; + }, [history.foo]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + var history = props.history; + + useEffect(() { + return [ + history?.foo + ]; + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'history?.foo\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [history?.foo]', + 'output': r''' + final MyComponent = uiFunction((props) { + var history = props.history; + + useEffect(() { + return [ + history?.foo + ]; + }, [history?.foo]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((_) { + useEffect(() {}, ['foo']); + }, null); + ''', + 'errors': [ + { + 'message': + // Don't assume user meant '''foo''' because it's not used in the effect. + 'The \'foo\' literal is not a valid dependency because it never changes. You can safely remove it.', + // TODO(ported):provide suggestion. + 'suggestions': null, + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + var foo = props.foo; + var bar = props.bar; + var baz = props.baz; + + useEffect(() { + print([foo, bar, baz]); + }, ['foo', 'bar']); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has missing dependencies: \'bar\', \'baz\', and \'foo\'. Either include them or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [bar, baz, foo]', + 'output': r''' + final MyComponent = uiFunction((props) { + var foo = props.foo; + var bar = props.bar; + var baz = props.baz; + + useEffect(() { + print([foo, bar, baz]); + }, [bar, baz, foo]); + }, null); + ''', + }, + ], + }, + { + 'message': + 'The \'foo\' literal is not a valid dependency because it never changes. Did you mean to include foo in the list instead?', + 'suggestions': null, + }, + { + 'message': + 'The \'bar\' literal is not a valid dependency because it never changes. Did you mean to include bar in the list instead?', + 'suggestions': null, + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + var foo = props.foo; + var bar = props.bar; + var baz = props.baz; + + useEffect(() { + print([foo, bar, baz]); + }, [42, false, null]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has missing dependencies: \'bar\', \'baz\', and \'foo\'. Either include them or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [bar, baz, foo]', + 'output': r''' + final MyComponent = uiFunction((props) { + var foo = props.foo; + var bar = props.bar; + var baz = props.baz; + + useEffect(() { + print([foo, bar, baz]); + }, [bar, baz, foo]); + }, null); + ''', + }, + ], + }, + { + 'message': 'The 42 literal is not a valid dependency because it never changes. You can safely remove it.', + 'suggestions': null, + }, + { + 'message': 'The false literal is not a valid dependency because it never changes. You can safely remove it.', + 'suggestions': null, + }, + { + 'message': 'The null literal is not a valid dependency because it never changes. You can safely remove it.', + 'suggestions': null, + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((_) { + final dependencies = []; + useEffect(() {}, dependencies); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect was passed a dependency list that is not a list literal. This means we can\'t statically verify whether you\'ve passed the correct dependencies.', + 'suggestions': null, + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((_) { + final local = {}; + final dependencies = [local]; + useEffect(() { + print(local); + }, dependencies); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect was passed a dependency list that is not a list literal. This means we can\'t statically verify whether you\'ve passed the correct dependencies.', + // TODO(ported):should this autofix or bail out? + 'suggestions': null, + }, + { + 'message': + 'React Hook useEffect has a missing dependency: \'local\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [local]', + 'output': r''' + final MyComponent = uiFunction((_) { + final local = {}; + final dependencies = [local]; + useEffect(() { + print(local); + }, [local]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((_) { + final local = {}; + final dependencies = [local]; + useEffect(() { + print(local); + }, [...dependencies]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'local\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [local]', + 'output': r''' + final MyComponent = uiFunction((_) { + final local = {}; + final dependencies = [local]; + useEffect(() { + print(local); + }, [local]); + }, null); + ''', + }, + ], + }, + { + 'message': + 'React Hook useEffect has a spread element in its dependency list. This means we can\'t statically verify whether you\'ve passed the correct dependencies.', + // TODO(ported):should this autofix or bail out? + 'suggestions': null, + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((_) { + final local = someFunc(); + useEffect(() { + print(local); + // ignore: undefined_identifier + }, [local, ...dependencies]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a spread element in its dependency list. This means we can\'t statically verify whether you\'ve passed the correct dependencies.', + 'suggestions': null, + }, + ], + }, + { + 'code': r''' + String computeCacheKey(dynamic object) => null; + final MyComponent = uiFunction((_) { + final local = {}; + useEffect(() { + print(local); + }, [computeCacheKey(local)]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'local\'. Either include it or remove the dependency list.', + // TODO(ported):I'm not sure this is a good idea. + // Maybe bail out? + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [local]', + 'output': r''' + String computeCacheKey(dynamic object) => null; + final MyComponent = uiFunction((_) { + final local = {}; + useEffect(() { + print(local); + }, [local]); + }, null); + ''', + }, + ], + }, + { + 'message': + 'React Hook useEffect has a complex expression in the dependency list. Extract it to a separate variable so it can be statically checked.', + 'suggestions': null, + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + useEffect(() { + print(props.items[0]); + }, [props.items[0]]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'props.items\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [props.items]', + 'output': r''' + final MyComponent = uiFunction((props) { + useEffect(() { + print(props.items[0]); + }, [props.items]); + }, null); + ''', + }, + ], + }, + { + 'message': + 'React Hook useEffect has a complex expression in the dependency list. Extract it to a separate variable so it can be statically checked.', + 'suggestions': null, + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + useEffect(() { + print(props.items[0]); + }, [props.items, props.items[0]]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a complex expression in the dependency list. Extract it to a separate variable so it can be statically checked.', + // TODO(ported):ideally suggestion would remove the bad expression? + 'suggestions': null, + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + var items = props.items; + + useEffect(() { + print(items[0]); + }, [items[0]]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'items\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [items]', + 'output': r''' + final MyComponent = uiFunction((props) { + var items = props.items; + + useEffect(() { + print(items[0]); + }, [items]); + }, null); + ''', + }, + ], + }, + { + 'message': + 'React Hook useEffect has a complex expression in the dependency list. Extract it to a separate variable so it can be statically checked.', + 'suggestions': null, + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + var items = props.items; + + useEffect(() { + print(items[0]); + }, [items, items[0]]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a complex expression in the dependency list. Extract it to a separate variable so it can be statically checked.', + // TODO(ported):ideally suggeston would remove the bad expression? + 'suggestions': null, + }, + ], + }, + { + // It is not valid for useCallback to specify extraneous deps + // because it doesn't serve as a side effect trigger unlike useEffect. + // However, we generally allow specifying *broader* deps as escape hatch. + // So while [props, props.foo] is unnecessary, 'props' wins here as the + // broader one, and this is why 'props.foo' is reported as unnecessary. + 'code': r''' + final MyComponent = uiFunction((props) { + final local = {}; + useCallback(() { + print(props.foo); + print(props.bar); + }, [props, props.foo]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useCallback has an unnecessary dependency: \'props.foo\'. Either exclude it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [props]', + 'output': r''' + final MyComponent = uiFunction((props) { + final local = {}; + useCallback(() { + print(props.foo); + print(props.bar); + }, [props]); + }, null); + ''', + }, + ], + }, + ], + }, + { + // Since we don't have 'props' in the list, we'll suggest narrow dependencies. + 'code': r''' + final MyComponent = uiFunction((props) { + final local = {}; + useCallback(() { + print(props.foo); + print(props.bar); + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useCallback has missing dependencies: \'props.bar\' and \'props.foo\'. Either include them or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [props.bar, props.foo]', + 'output': r''' + final MyComponent = uiFunction((props) { + final local = {}; + useCallback(() { + print(props.foo); + print(props.bar); + }, [props.bar, props.foo]); + }, null); + ''', + }, + ], + }, + ], + }, + { + // Effects are allowed to over-specify deps. We'll complain about missing + // 'local', but we won't remove the already-specified 'local.id' from your list. + 'code': r''' + final MyComponent = uiFunction((_) { + final local = SomeObject(id: 42); + useEffect(() { + print(local); + }, [local.id]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'local\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [local, local.id]', + 'output': r''' + final MyComponent = uiFunction((_) { + final local = SomeObject(id: 42); + useEffect(() { + print(local); + }, [local, local.id]); + }, null); + ''', + }, + ], + }, + ], + }, + { + // Callbacks are not allowed to over-specify deps. So we'll complain about missing + // 'local' and we will also *remove* 'local.id' from your list. + 'code': r''' + final MyComponent = uiFunction((_) { + final local = SomeObject(id: 42); + final fn = useCallback(() { + print(local); + }, [local.id]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useCallback has a missing dependency: \'local\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [local]', + 'output': r''' + final MyComponent = uiFunction((_) { + final local = SomeObject(id: 42); + final fn = useCallback(() { + print(local); + }, [local]); + }, null); + ''', + }, + ], + }, + ], + }, + { + // Callbacks are not allowed to over-specify deps. So we'll complain about + // the unnecessary 'local.id'. + 'code': r''' + final MyComponent = uiFunction((_) { + final local = SomeObject(id: 42); + final fn = useCallback(() { + print(local); + }, [local.id, local]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useCallback has an unnecessary dependency: \'local.id\'. Either exclude it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [local]', + 'output': r''' + final MyComponent = uiFunction((_) { + final local = SomeObject(id: 42); + final fn = useCallback(() { + print(local); + }, [local]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + final fn = useCallback(() { + print(props.foo.bar.baz); + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useCallback has a missing dependency: \'props.foo.bar.baz\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [props.foo.bar.baz]', + 'output': r''' + final MyComponent = uiFunction((props) { + final fn = useCallback(() { + print(props.foo.bar.baz); + }, [props.foo.bar.baz]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + var color = {}; + final fn = useCallback(() { + print(props.foo.bar.baz); + print(color); + }, [props.foo, props.foo.bar.baz]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useCallback has a missing dependency: \'color\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [color, props.foo.bar.baz]', + 'output': r''' + final MyComponent = uiFunction((props) { + var color = {}; + final fn = useCallback(() { + print(props.foo.bar.baz); + print(color); + }, [color, props.foo.bar.baz]); + }, null); + ''', + }, + ], + }, + ], + }, + { + // Callbacks are not allowed to over-specify deps. So one of these is extra. + // However, it *is* allowed to specify broader deps then strictly necessary. + // So in this case we ask you to remove 'props.foo.bar.baz' because 'props.foo' + // already covers it, and having both is unnecessary. + // TODO(ported):maybe consider suggesting a narrower one by default in these cases. + 'code': r''' + final MyComponent = uiFunction((props) { + final fn = useCallback(() { + print(props.foo.bar.baz); + }, [props.foo.bar.baz, props.foo]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useCallback has an unnecessary dependency: \'props.foo.bar.baz\'. Either exclude it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [props.foo]', + 'output': r''' + final MyComponent = uiFunction((props) { + final fn = useCallback(() { + print(props.foo.bar.baz); + }, [props.foo]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + final fn = useCallback(() { + print(props.foo.bar.baz); + print(props.foo.fizz.bizz); + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useCallback has missing dependencies: \'props.foo.bar.baz\' and \'props.foo.fizz.bizz\'. Either include them or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [props.foo.bar.baz, props.foo.fizz.bizz]', + 'output': r''' + final MyComponent = uiFunction((props) { + final fn = useCallback(() { + print(props.foo.bar.baz); + print(props.foo.fizz.bizz); + }, [props.foo.bar.baz, props.foo.fizz.bizz]); + }, null); + ''', + }, + ], + }, + ], + }, + { + // Normally we allow specifying deps too broadly. + // So we'd be okay if 'props.foo.bar' was there rather than 'props.foo.bar.baz'. + // However, 'props.foo.bar.baz' is missing. So we know there is a mistake. + // When we're sure there is a mistake, for callbacks we will rebuild the list + // from scratch. This will set the user on a better path by default. + // This is why we end up with just 'props.foo.bar', and not them both. + 'code': r''' + final MyComponent = uiFunction((props) { + final fn = useCallback(() { + print(props.foo.bar); + }, [props.foo.bar.baz]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useCallback has a missing dependency: \'props.foo.bar\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [props.foo.bar]', + 'output': r''' + final MyComponent = uiFunction((props) { + final fn = useCallback(() { + print(props.foo.bar); + }, [props.foo.bar]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + final fn = useCallback(() { + print(props); + print(props.hello); + }, [props.foo.bar.baz]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useCallback has a missing dependency: \'props\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [props]', + 'output': r''' + final MyComponent = uiFunction((props) { + final fn = useCallback(() { + print(props); + print(props.hello); + }, [props]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((_) { + final local = {}; + useEffect(() { + print(local); + }, [local, local]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a duplicate dependency: \'local\'. Either omit it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [local]', + 'output': r''' + final MyComponent = uiFunction((_) { + final local = {}; + useEffect(() { + print(local); + }, [local]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((_) { + final local1 = {}; + useCallback(() { + final local1 = {}; + print(local1); + }, [local1]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useCallback has an unnecessary dependency: \'local1\'. Either exclude it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: []', + 'output': r''' + final MyComponent = uiFunction((_) { + final local1 = {}; + useCallback(() { + final local1 = {}; + print(local1); + }, []); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((_) { + final local1 = {}; + useCallback(() {}, [local1]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useCallback has an unnecessary dependency: \'local1\'. Either exclude it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: []', + 'output': r''' + final MyComponent = uiFunction((_) { + final local1 = {}; + useCallback(() {}, []); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + useEffect(() { + print(props.foo); + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'props.foo\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [props.foo]', + 'output': r''' + final MyComponent = uiFunction((props) { + useEffect(() { + print(props.foo); + }, [props.foo]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + useEffect(() { + print(props.foo); + print(props.bar); + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has missing dependencies: \'props.bar\' and \'props.foo\'. Either include them or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [props.bar, props.foo]', + 'output': r''' + final MyComponent = uiFunction((props) { + useEffect(() { + print(props.foo); + print(props.bar); + }, [props.bar, props.foo]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + var a, b, c, d, e, f, g; + useEffect(() { + print([b, e, d, c, a, g, f]); + }, [c, a, g]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has missing dependencies: \'b\', \'d\', \'e\', and \'f\'. Either include them or remove the dependency list.', + // Don't alphabetize if it wasn't alphabetized in the first place. + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [c, a, g, b, e, d, f]', + 'output': r''' + final MyComponent = uiFunction((props) { + var a, b, c, d, e, f, g; + useEffect(() { + print([b, e, d, c, a, g, f]); + }, [c, a, g, b, e, d, f]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + var a, b, c, d, e, f, g; + useEffect(() { + print([b, e, d, c, a, g, f]); + }, [a, c, g]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has missing dependencies: \'b\', \'d\', \'e\', and \'f\'. Either include them or remove the dependency list.', + // Alphabetize if it was alphabetized. + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [a, b, c, d, e, f, g]', + 'output': r''' + final MyComponent = uiFunction((props) { + var a, b, c, d, e, f, g; + useEffect(() { + print([b, e, d, c, a, g, f]); + }, [a, b, c, d, e, f, g]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'name': 'Formats dependencies in fix with newlines if old dependencies had newlines', + 'code': r''' + final MyComponent = uiFunction((props) { + var a, b, c, d; + useEffect(() { + print([a, b, c, d]); + }, [ + a, + b + ]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has missing dependencies: \'c\' and \'d\'. Either include them or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [a, b, c, d]', + 'output': r''' + final MyComponent = uiFunction((props) { + var a, b, c, d; + useEffect(() { + print([a, b, c, d]); + }, [ + a, + b, + c, + d + ]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'name': 'Formats dependencies in fix with trailing commas if old dependencies had trailing commas', + 'code': r''' + final MyComponent = uiFunction((props) { + var a, b, c, d; + useEffect(() { + print([a, b, c, d]); + }, [ + a, + b, + ]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has missing dependencies: \'c\' and \'d\'. Either include them or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [a, b, c, d]', + 'output': r''' + final MyComponent = uiFunction((props) { + var a, b, c, d; + useEffect(() { + print([a, b, c, d]); + }, [ + a, + b, + c, + d, + ]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + var a, b, c, d, e, f, g; + useEffect(() { + print([b, e, d, c, a, g, f]); + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has missing dependencies: \'a\', \'b\', \'c\', \'d\', \'e\', \'f\', and \'g\'. Either include them or remove the dependency list.', + // Alphabetize if it was empty. + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [a, b, c, d, e, f, g]', + 'output': r''' + final MyComponent = uiFunction((props) { + var a, b, c, d, e, f, g; + useEffect(() { + print([b, e, d, c, a, g, f]); + }, [a, b, c, d, e, f, g]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + final local = {}; + useEffect(() { + print(props.foo); + print(props.bar); + print(local); + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has missing dependencies: \'local\', \'props.bar\', and \'props.foo\'. Either include them or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [local, props.bar, props.foo]', + 'output': r''' + final MyComponent = uiFunction((props) { + final local = {}; + useEffect(() { + print(props.foo); + print(props.bar); + print(local); + }, [local, props.bar, props.foo]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + final local = {}; + useEffect(() { + print(props.foo); + print(props.bar); + print(local); + }, [props]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'local\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [local, props]', + 'output': r''' + final MyComponent = uiFunction((props) { + final local = {}; + useEffect(() { + print(props.foo); + print(props.bar); + print(local); + }, [local, props]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + useEffect(() { + print(props.foo); + }, []); + useCallback(() { + print(props.foo); + }, []); + useMemo(() { + print(props.foo); + }, []); + over_react.useEffect(() { + print(props.bar); + }, []); + over_react.useCallback(() { + print(props.bar); + }, []); + over_react.useMemo(() { + print(props.bar); + }, []); + // ignore: undefined_function + over_react.notReactiveHook(() { + print(props.bar); + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'props.foo\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [props.foo]', + 'output': r''' + final MyComponent = uiFunction((props) { + useEffect(() { + print(props.foo); + }, [props.foo]); + useCallback(() { + print(props.foo); + }, []); + useMemo(() { + print(props.foo); + }, []); + over_react.useEffect(() { + print(props.bar); + }, []); + over_react.useCallback(() { + print(props.bar); + }, []); + over_react.useMemo(() { + print(props.bar); + }, []); + // ignore: undefined_function + over_react.notReactiveHook(() { + print(props.bar); + }, []); + }, null); + ''', + }, + ], + }, + { + 'message': + 'React Hook useCallback has a missing dependency: \'props.foo\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [props.foo]', + 'output': r''' + final MyComponent = uiFunction((props) { + useEffect(() { + print(props.foo); + }, []); + useCallback(() { + print(props.foo); + }, [props.foo]); + useMemo(() { + print(props.foo); + }, []); + over_react.useEffect(() { + print(props.bar); + }, []); + over_react.useCallback(() { + print(props.bar); + }, []); + over_react.useMemo(() { + print(props.bar); + }, []); + // ignore: undefined_function + over_react.notReactiveHook(() { + print(props.bar); + }, []); + }, null); + ''', + }, + ], + }, + { + 'message': + 'React Hook useMemo has a missing dependency: \'props.foo\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [props.foo]', + 'output': r''' + final MyComponent = uiFunction((props) { + useEffect(() { + print(props.foo); + }, []); + useCallback(() { + print(props.foo); + }, []); + useMemo(() { + print(props.foo); + }, [props.foo]); + over_react.useEffect(() { + print(props.bar); + }, []); + over_react.useCallback(() { + print(props.bar); + }, []); + over_react.useMemo(() { + print(props.bar); + }, []); + // ignore: undefined_function + over_react.notReactiveHook(() { + print(props.bar); + }, []); + }, null); + ''', + }, + ], + }, + { + 'message': + 'React Hook useEffect has a missing dependency: \'props.bar\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [props.bar]', + 'output': r''' + final MyComponent = uiFunction((props) { + useEffect(() { + print(props.foo); + }, []); + useCallback(() { + print(props.foo); + }, []); + useMemo(() { + print(props.foo); + }, []); + over_react.useEffect(() { + print(props.bar); + }, [props.bar]); + over_react.useCallback(() { + print(props.bar); + }, []); + over_react.useMemo(() { + print(props.bar); + }, []); + // ignore: undefined_function + over_react.notReactiveHook(() { + print(props.bar); + }, []); + }, null); + ''', + }, + ], + }, + { + 'message': + 'React Hook useCallback has a missing dependency: \'props.bar\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [props.bar]', + 'output': r''' + final MyComponent = uiFunction((props) { + useEffect(() { + print(props.foo); + }, []); + useCallback(() { + print(props.foo); + }, []); + useMemo(() { + print(props.foo); + }, []); + over_react.useEffect(() { + print(props.bar); + }, []); + over_react.useCallback(() { + print(props.bar); + }, [props.bar]); + over_react.useMemo(() { + print(props.bar); + }, []); + // ignore: undefined_function + over_react.notReactiveHook(() { + print(props.bar); + }, []); + }, null); + ''', + }, + ], + }, + { + 'message': + 'React Hook useMemo has a missing dependency: \'props.bar\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [props.bar]', + 'output': r''' + final MyComponent = uiFunction((props) { + useEffect(() { + print(props.foo); + }, []); + useCallback(() { + print(props.foo); + }, []); + useMemo(() { + print(props.foo); + }, []); + over_react.useEffect(() { + print(props.bar); + }, []); + over_react.useCallback(() { + print(props.bar); + }, []); + over_react.useMemo(() { + print(props.bar); + }, [props.bar]); + // ignore: undefined_function + over_react.notReactiveHook(() { + print(props.bar); + }, []); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + useCustomEffect(() { + print(props.foo); + }, []); + useEffect(() { + print(props.foo); + }, []); + over_react.useEffect(() { + print(props.bar); + }, []); + // ignore: undefined_function + over_react.useCustomEffect(() { + print(props.bar); + }, []); + }, null); + ''', + 'options': [ + {'additionalHooks': 'useCustomEffect'} + ], + 'errors': [ + { + 'message': + 'React Hook useCustomEffect has a missing dependency: \'props.foo\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [props.foo]', + 'output': r''' + final MyComponent = uiFunction((props) { + useCustomEffect(() { + print(props.foo); + }, [props.foo]); + useEffect(() { + print(props.foo); + }, []); + over_react.useEffect(() { + print(props.bar); + }, []); + // ignore: undefined_function + over_react.useCustomEffect(() { + print(props.bar); + }, []); + }, null); + ''', + }, + ], + }, + { + 'message': + 'React Hook useEffect has a missing dependency: \'props.foo\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [props.foo]', + 'output': r''' + final MyComponent = uiFunction((props) { + useCustomEffect(() { + print(props.foo); + }, []); + useEffect(() { + print(props.foo); + }, [props.foo]); + over_react.useEffect(() { + print(props.bar); + }, []); + // ignore: undefined_function + over_react.useCustomEffect(() { + print(props.bar); + }, []); + }, null); + ''', + }, + ], + }, + { + 'message': + 'React Hook useEffect has a missing dependency: \'props.bar\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [props.bar]', + 'output': r''' + final MyComponent = uiFunction((props) { + useCustomEffect(() { + print(props.foo); + }, []); + useEffect(() { + print(props.foo); + }, []); + over_react.useEffect(() { + print(props.bar); + }, [props.bar]); + // ignore: undefined_function + over_react.useCustomEffect(() { + print(props.bar); + }, []); + }, null); + ''', + }, + ], + }, + { + 'message': + 'React Hook useCustomEffect has a missing dependency: \'props.bar\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [props.bar]', + 'output': r''' + final MyComponent = uiFunction((props) { + useCustomEffect(() { + print(props.foo); + }, []); + useEffect(() { + print(props.foo); + }, []); + over_react.useEffect(() { + print(props.bar); + }, []); + // ignore: undefined_function + over_react.useCustomEffect(() { + print(props.bar); + }, [props.bar]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((_) { + var a, b; + final local = {}; + useEffect(() { + print(local); + }, [a ? local : b]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'local\'. Either include it or remove the dependency list.', + // TODO(ported):should we bail out instead? + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [local]', + 'output': r''' + final MyComponent = uiFunction((_) { + var a, b; + final local = {}; + useEffect(() { + print(local); + }, [local]); + }, null); + ''', + }, + ], + }, + { + 'message': + 'React Hook useEffect has a complex expression in the dependency list. Extract it to a separate variable so it can be statically checked.', + 'suggestions': null, + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((_) { + var a; + final local = {}; + useEffect(() { + print(local); + }, [a ?? local]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'local\'. Either include it or remove the dependency list.', + // TODO(ported):should we bail out instead? + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [local]', + 'output': r''' + final MyComponent = uiFunction((_) { + var a; + final local = {}; + useEffect(() { + print(local); + }, [local]); + }, null); + ''', + }, + ], + }, + { + 'message': + 'React Hook useEffect has a complex expression in the dependency list. Extract it to a separate variable so it can be statically checked.', + 'suggestions': null, + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + useEffect(() {}, [props?.attribute.method()]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a complex expression in the dependency list. Extract it to a separate variable so it can be statically checked.', + 'suggestions': null, + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + useEffect(() {}, [props.function()]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a complex expression in the dependency list. Extract it to a separate variable so it can be statically checked.', + 'suggestions': null, + }, + ], + }, + { + 'name': 'Cascade in dependencies list', + 'code': r''' + final MyComponent = uiFunction((props) { + useEffect(() {}, [props..function()]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a complex expression in the dependency list. Extract it to a separate variable so it can be statically checked.', + 'suggestions': null, + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((_) { + final ref = useRef(); + var state = useState(0); + useEffect(() { + ref.current = {}; + state.set(state.value + 1); + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'state.value\'. Either include it or remove the dependency list. You can also do a functional update \'state.setWithUpdater((s) => ...)\' if you only need \'state.value\' in the \'state.set\' call.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [state.value]', + 'output': r''' + final MyComponent = uiFunction((_) { + final ref = useRef(); + var state = useState(0); + useEffect(() { + ref.current = {}; + state.set(state.value + 1); + }, [state.value]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((_) { + final ref = useRef(); + var state = useState(null); + useEffect(() { + ref.current = {}; + state.set(state.value + 1); + }, [ref]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'state.value\'. Either include it or remove the dependency list. You can also do a functional update \'state.setWithUpdater((s) => ...)\' if you only need \'state.value\' in the \'state.set\' call.', + // We don't ask to remove static deps but don't add them either. + // Don't suggest removing "ref" (it's fine either way) + // but *do* add "state". *Don't* add "setState" ourselves. + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [ref, state.value]', + 'output': r''' + final MyComponent = uiFunction((_) { + final ref = useRef(); + var state = useState(null); + useEffect(() { + ref.current = {}; + state.set(state.value + 1); + }, [ref, state.value]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + final ref1 = useRef(); + final ref2 = useRef(); + useEffect(() { + ref1.current.focus(); + print(ref2.current.textContent); + window.alert(props.someOtherRefs.current.innerHTML); + fetch(props.color); + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has missing dependencies: \'props.color\' and \'props.someOtherRefs\'. Either include them or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [props.color, props.someOtherRefs]', + 'output': r''' + final MyComponent = uiFunction((props) { + final ref1 = useRef(); + final ref2 = useRef(); + useEffect(() { + ref1.current.focus(); + print(ref2.current.textContent); + window.alert(props.someOtherRefs.current.innerHTML); + fetch(props.color); + }, [props.color, props.someOtherRefs]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + final ref1 = useRef(); + final ref2 = useRef(); + useEffect(() { + ref1.current.focus(); + print(ref2.current.textContent); + window.alert(props.someOtherRefs.current.innerHTML); + fetch(props.color); + }, [ref1.current, ref2.current, props.someOtherRefs, props.color]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has unnecessary dependencies: \'ref1.current\' and \'ref2.current\'. Either exclude them or remove the dependency list. Mutable values like \'ref1.current\' aren\'t valid dependencies because mutating them doesn\'t re-render the component.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [props.someOtherRefs, props.color]', + 'output': r''' + final MyComponent = uiFunction((props) { + final ref1 = useRef(); + final ref2 = useRef(); + useEffect(() { + ref1.current.focus(); + print(ref2.current.textContent); + window.alert(props.someOtherRefs.current.innerHTML); + fetch(props.color); + }, [props.someOtherRefs, props.color]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + final ref1 = useRef(); + final ref2 = useRef(); + useEffect(() { + ref1?.current?.focus(); + print(ref2?.current?.textContent); + window.alert(props.someOtherRefs.current.innerHTML); + fetch(props.color); + }, [ref1?.current, ref2?.current, props.someOtherRefs, props.color]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has unnecessary dependencies: \'ref1.current\' and \'ref2.current\'. Either exclude them or remove the dependency list. Mutable values like \'ref1.current\' aren\'t valid dependencies because mutating them doesn\'t re-render the component.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [props.someOtherRefs, props.color]', + 'output': r''' + final MyComponent = uiFunction((props) { + final ref1 = useRef(); + final ref2 = useRef(); + useEffect(() { + ref1?.current?.focus(); + print(ref2?.current?.textContent); + window.alert(props.someOtherRefs.current.innerHTML); + fetch(props.color); + }, [props.someOtherRefs, props.color]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((_) { + final ref = useRef(); + useEffect(() { + print(ref.current); + }, [ref.current]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has an unnecessary dependency: \'ref.current\'. Either exclude it or remove the dependency list. Mutable values like \'ref.current\' aren\'t valid dependencies because mutating them doesn\'t re-render the component.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: []', + 'output': r''' + final MyComponent = uiFunction((_) { + final ref = useRef(); + useEffect(() { + print(ref.current); + }, []); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + var activeTab = props.activeTab; + + final ref1 = useRef(); + final ref2 = useRef(); + useEffect(() { + ref1.current.scrollTop = 0; + ref2.current.scrollTop = 0; + }, [ref1.current, ref2.current, activeTab]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has unnecessary dependencies: \'ref1.current\' and \'ref2.current\'. Either exclude them or remove the dependency list. Mutable values like \'ref1.current\' aren\'t valid dependencies because mutating them doesn\'t re-render the component.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [activeTab]', + 'output': r''' + final MyComponent = uiFunction((props) { + var activeTab = props.activeTab; + + final ref1 = useRef(); + final ref2 = useRef(); + useEffect(() { + ref1.current.scrollTop = 0; + ref2.current.scrollTop = 0; + }, [activeTab]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + var activeTab = props.activeTab; + var initY = props.initY; + + final ref1 = useRef(); + final ref2 = useRef(); + final fn = useCallback(() { + ref1.current.scrollTop = initY; + ref2.current.scrollTop = initY; + }, [ref1.current, ref2.current, activeTab, initY]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useCallback has unnecessary dependencies: \'activeTab\', \'ref1.current\', and \'ref2.current\'. Either exclude them or remove the dependency list. Mutable values like \'ref1.current\' aren\'t valid dependencies because mutating them doesn\'t re-render the component.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [initY]', + 'output': r''' + final MyComponent = uiFunction((props) { + var activeTab = props.activeTab; + var initY = props.initY; + + final ref1 = useRef(); + final ref2 = useRef(); + final fn = useCallback(() { + ref1.current.scrollTop = initY; + ref2.current.scrollTop = initY; + }, [initY]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((_) { + final ref = useRef(); + useEffect(() { + print(ref.current); + }, [ref.current, ref]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has an unnecessary dependency: \'ref.current\'. Either exclude it or remove the dependency list. Mutable values like \'ref.current\' aren\'t valid dependencies because mutating them doesn\'t re-render the component.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [ref]', + 'output': r''' + final MyComponent = uiFunction((_) { + final ref = useRef(); + useEffect(() { + print(ref.current); + }, [ref]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiForwardRef((props, ref) { + useImperativeHandle(ref, () => ({ + 'focus': () { + window.alert(props.hello); + } + }), []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useImperativeHandle has a missing dependency: \'props.hello\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [props.hello]', + 'output': r''' + final MyComponent = uiForwardRef((props, ref) { + useImperativeHandle(ref, () => ({ + 'focus': () { + window.alert(props.hello); + } + }), [props.hello]); + }, null); + ''', + }, + ], + }, + ], + }, + { + // In this case it's important that the call is the only reference to the prop + 'name': 'Calling a function prop', + 'code': r''' + final MyComponent = uiFunction((props) { + useEffect(() { + props.onChange(null); + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'props.onChange\'. Either include it or remove the dependency list. If \'props.onChange\' changes too often, find the parent component that defines it and wrap that definition in useCallback.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [props.onChange]', + 'output': r''' + final MyComponent = uiFunction((props) { + useEffect(() { + props.onChange(null); + }, [props.onChange]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'name': 'Calling a function prop with another non-call reference present', + 'code': r''' + final MyComponent = uiFunction((props) { + useEffect(() { + if (props.onChange != null) { + props.onChange(null); + } + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'props.onChange\'. Either include it or remove the dependency list. If \'props.onChange\' changes too often, find the parent component that defines it and wrap that definition in useCallback.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [props.onChange]', + 'output': r''' + final MyComponent = uiFunction((props) { + useEffect(() { + if (props.onChange != null) { + props.onChange(null); + } + }, [props.onChange]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'name': 'Calling a function prop with a null-aware on props', + 'code': r''' + final MyComponent = uiFunction((props) { + useEffect(() { + props?.onChange(null); + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'props?.onChange\'. Either include it or remove the dependency list. If \'props?.onChange\' changes too often, find the parent component that defines it and wrap that definition in useCallback.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [props?.onChange]', + 'output': r''' + final MyComponent = uiFunction((props) { + useEffect(() { + props?.onChange(null); + }, [props?.onChange]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'name': 'Calling a function prop with a null-aware on the call', + 'code': r''' + final MyComponent = uiFunction((props) { + useEffect(() { + props.onChange?.call(null); + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'props.onChange\'. Either include it or remove the dependency list. If \'props.onChange\' changes too often, find the parent component that defines it and wrap that definition in useCallback.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [props.onChange]', + 'output': r''' + final MyComponent = uiFunction((props) { + useEffect(() { + props.onChange?.call(null); + }, [props.onChange]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'name': 'Calling function props in a cascade', + 'code': r''' + final MyComponent = uiFunction((props) { + useEffect(() { + props + ..onClick(null) + ..onChange(null); + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect most likely has issues in its dependencies list, but the exact problems and recommended fixes could not be be computed since the dependency \'props\' is the target of a cascade. Try refactoring to not cascade on that dependency in the callback to get more helpful instructions and potentially a suggested fix.', + 'suggestions': null, + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + useEffect(() { + play() { + props.onPlay(); + } + pause() { + props.onPause(); + } + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has missing dependencies: \'props.onPause\' and \'props.onPlay\'. Either include them or remove the dependency list. If any of \'props.onPause\' or \'props.onPlay\' changes too often, find the parent component that defines it and wrap that definition in useCallback.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [props.onPause, props.onPlay]', + 'output': r''' + final MyComponent = uiFunction((props) { + useEffect(() { + play() { + props.onPlay(); + } + pause() { + props.onPause(); + } + }, [props.onPause, props.onPlay]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + useEffect(() { + if (props.foo.onChange != null) { + props.foo.onChange(); + } + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'props.foo\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [props.foo]', + 'output': r''' + final MyComponent = uiFunction((props) { + useEffect(() { + if (props.foo.onChange != null) { + props.foo.onChange(); + } + }, [props.foo]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'name': 'Calling a function property of an object without any dependencies', + 'code': r''' + final MyComponent = uiFunction((props) { + useEffect(() { + props.foo.onChange(); + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'props.foo\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [props.foo]', + 'output': r''' + final MyComponent = uiFunction((props) { + useEffect(() { + props.foo.onChange(); + }, [props.foo]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + useEffect(() { + props.onChange(null); + if (props.foo.onChange) { + props.foo.onChange(); + } + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has missing dependencies: \'props.foo\' and \'props.onChange\'. Either include them or remove the dependency list. If \'props.onChange\' changes too often, find the parent component that defines it and wrap that definition in useCallback.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [props.foo, props.onChange]', + 'output': r''' + final MyComponent = uiFunction((props) { + useEffect(() { + props.onChange(null); + if (props.foo.onChange) { + props.foo.onChange(); + } + }, [props.foo, props.onChange]); + }, null); + ''', + }, + ], + }, + ], + }, + /* 1 test case previously here was moved to be a valid test case, since it involved a function prop call declared as a dependency, which is now allowed */ + { + 'code': r''' + final MyComponent = uiFunction((props) { + final skillsCount = useState(null); + useEffect(() { + if (skillsCount.value == 0 && !props.isEditMode) { + props.toggleEditMode(); + } + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has missing dependencies: \'props.isEditMode\', \'props.toggleEditMode\', and \'skillsCount.value\'. Either include them or remove the dependency list. If \'props.toggleEditMode\' changes too often, find the parent component that defines it and wrap that definition in useCallback.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [props.isEditMode, props.toggleEditMode, skillsCount.value]', + 'output': r''' + final MyComponent = uiFunction((props) { + final skillsCount = useState(null); + useEffect(() { + if (skillsCount.value == 0 && !props.isEditMode) { + props.toggleEditMode(); + } + }, [props.isEditMode, props.toggleEditMode, skillsCount.value]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + void externalCall(dynamic arg) {} + final MyComponent = uiFunction((props) { + useEffect(() { + externalCall(props); + props.onChange(null); + }, []); + }, null); + ''', + // Don't suggest to destructure props here since you can't. + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'props\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [props]', + 'output': r''' + void externalCall(dynamic arg) {} + final MyComponent = uiFunction((props) { + useEffect(() { + externalCall(props); + props.onChange(null); + }, [props]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + void externalCall(dynamic arg) {} + final MyComponent = uiFunction((props) { + useEffect(() { + props.onChange(null); + externalCall(props); + }, []); + }, null); + ''', + // Don't suggest to destructure props here since you can't. + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'props\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [props]', + 'output': r''' + void externalCall(dynamic arg) {} + final MyComponent = uiFunction((props) { + useEffect(() { + props.onChange(null); + externalCall(props); + }, [props]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + var value; + var value2; + var value3; + var value4; + var asyncValue; + useEffect(() { + if (value4) { + value = {}; + } + value2 = 100; + value = 43; + value4 = true; + print(value2); + print(value3); + setTimeout(() { + asyncValue = 100; + }); + }, []); + }, null); + ''', + // This is a separate warning unrelated to others. + // We could've made a separate rule for it but it's rare enough to name it. + // No suggestions because the intent isn't clear. + 'errors': [ + { + 'message': + // value2 + 'Assignments to the \'value2\' variable from inside React Hook useEffect will be lost after each render. To preserve the value over time, store it in a useRef Hook and keep the mutable value in the \'.current\' property. Otherwise, you can move this variable directly inside useEffect.', + 'suggestions': null, + }, + { + 'message': + // value + 'Assignments to the \'value\' variable from inside React Hook useEffect will be lost after each render. To preserve the value over time, store it in a useRef Hook and keep the mutable value in the \'.current\' property. Otherwise, you can move this variable directly inside useEffect.', + 'suggestions': null, + }, + { + 'message': + // value4 + 'Assignments to the \'value4\' variable from inside React Hook useEffect will be lost after each render. To preserve the value over time, store it in a useRef Hook and keep the mutable value in the \'.current\' property. Otherwise, you can move this variable directly inside useEffect.', + 'suggestions': null, + }, + { + 'message': + // asyncValue + 'Assignments to the \'asyncValue\' variable from inside React Hook useEffect will be lost after each render. To preserve the value over time, store it in a useRef Hook and keep the mutable value in the \'.current\' property. Otherwise, you can move this variable directly inside useEffect.', + 'suggestions': null, + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + var value; + var value2; + var value3; + var asyncValue; + useEffect(() { + value = {}; + value2 = 100; + value = 43; + print(value2); + print(value3); + setTimeout(() { + asyncValue = 100; + }); + }, [value, value2, value3]); + }, null); + ''', + // This is a separate warning unrelated to others. + // We could've made a separate rule for it but it's rare enough to name it. + // No suggestions because the intent isn't clear. + 'errors': [ + { + 'message': + // value + 'Assignments to the \'value\' variable from inside React Hook useEffect will be lost after each render. To preserve the value over time, store it in a useRef Hook and keep the mutable value in the \'.current\' property. Otherwise, you can move this variable directly inside useEffect.', + 'suggestions': null, + }, + { + 'message': + // value2 + 'Assignments to the \'value2\' variable from inside React Hook useEffect will be lost after each render. To preserve the value over time, store it in a useRef Hook and keep the mutable value in the \'.current\' property. Otherwise, you can move this variable directly inside useEffect.', + 'suggestions': null, + }, + { + 'message': + // asyncValue + 'Assignments to the \'asyncValue\' variable from inside React Hook useEffect will be lost after each render. To preserve the value over time, store it in a useRef Hook and keep the mutable value in the \'.current\' property. Otherwise, you can move this variable directly inside useEffect.', + 'suggestions': null, + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((_) { + final myRef = useRef(); + useEffect(() { + final handleMove = () {}; + myRef.current.addEventListener('mousemove', handleMove); + return () => myRef.current.removeEventListener('mousemove', handleMove); + }, []); + return (Dom.div()..ref = myRef)(); + }, null); + ''', + 'errors': [ + { + 'message': + 'The ref value \'myRef.current\' will likely have changed by the time this effect cleanup function runs. If this ref points to a node rendered by React, copy \'myRef.current\' to a variable inside the effect, and use that variable in the cleanup function.', + 'suggestions': null, + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((_) { + final myRef = useRef(); + useEffect(() { + final handleMove = () {}; + myRef?.current?.addEventListener('mousemove', handleMove); + return () => myRef?.current?.removeEventListener('mousemove', handleMove); + }, []); + return (Dom.div()..ref = myRef)(); + }, null); + ''', + 'errors': [ + { + 'message': + 'The ref value \'myRef.current\' will likely have changed by the time this effect cleanup function runs. If this ref points to a node rendered by React, copy \'myRef.current\' to a variable inside the effect, and use that variable in the cleanup function.', + 'suggestions': null, + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((_) { + final myRef = useRef(); + useEffect(() { + final handleMove = () {}; + myRef.current.addEventListener('mousemove', handleMove); + return () => myRef.current.removeEventListener('mousemove', handleMove); + }); + return (Dom.div()..ref = myRef)(); + }, null); + ''', + 'errors': [ + { + 'message': + 'The ref value \'myRef.current\' will likely have changed by the time this effect cleanup function runs. If this ref points to a node rendered by React, copy \'myRef.current\' to a variable inside the effect, and use that variable in the cleanup function.', + 'suggestions': null, + }, + ], + }, + { + 'code': r''' + useMyThing(myRef) { + useEffect(() { + final handleMove = () {}; + myRef.current.addEventListener('mousemove', handleMove); + return () => myRef.current.removeEventListener('mousemove', handleMove); + }, [myRef]); + } + ''', + 'errors': [ + { + 'message': + 'The ref value \'myRef.current\' will likely have changed by the time this effect cleanup function runs. If this ref points to a node rendered by React, copy \'myRef.current\' to a variable inside the effect, and use that variable in the cleanup function.', + 'suggestions': null, + }, + ], + }, + { + 'code': r''' + useMyThing(myRef) { + useEffect(() { + final handleMouse = () {}; + myRef.current.addEventListener('mousemove', handleMouse); + myRef.current.addEventListener('mousein', handleMouse); + return () { + setTimeout(() { + myRef.current.removeEventListener('mousemove', handleMouse); + myRef.current.removeEventListener('mousein', handleMouse); + }); + }; + }, [myRef]); + } + ''', + 'errors': [ + { + 'message': + 'The ref value \'myRef.current\' will likely have changed by the time this effect cleanup function runs. If this ref points to a node rendered by React, copy \'myRef.current\' to a variable inside the effect, and use that variable in the cleanup function.', + 'suggestions': null, + }, + ], + }, + { + 'code': r''' + useMyThing(myRef, active) { + useEffect(() { + final handleMove = () {}; + if (active) { + myRef.current.addEventListener('mousemove', handleMove); + return () { + setTimeout(() { + myRef.current.removeEventListener('mousemove', handleMove); + }); + }; + } + }, [myRef, active]); + } + ''', + 'errors': [ + { + 'message': + 'The ref value \'myRef.current\' will likely have changed by the time this effect cleanup function runs. If this ref points to a node rendered by React, copy \'myRef.current\' to a variable inside the effect, and use that variable in the cleanup function.', + 'suggestions': null, + }, + ], + }, + { + 'code': r''' + void useLayoutEffect_SAFE_FOR_SSR(dynamic Function() callback, [List dependencies]) {} + final MyComponent = uiFunction((_) { + final myRef = useRef(); + useLayoutEffect_SAFE_FOR_SSR(() { + final handleMove = () {}; + myRef.current.addEventListener('mousemove', handleMove); + return () => myRef.current.removeEventListener('mousemove', handleMove); + }); + return (Dom.div()..ref = myRef)(); + }, null); + ''', + 'output': r''' + void useLayoutEffect_SAFE_FOR_SSR(dynamic Function() callback, [List dependencies]) {} + final MyComponent = uiFunction((_) { + final myRef = useRef(); + useLayoutEffect_SAFE_FOR_SSR(() { + final handleMove = () {}; + myRef.current.addEventListener('mousemove', handleMove); + return () => myRef.current.removeEventListener('mousemove', handleMove); + }); + return (Dom.div()..ref = myRef)(); + }, null); + ''', + 'errors': [ + { + 'message': + 'The ref value \'myRef.current\' will likely have changed by the time this effect cleanup function runs. If this ref points to a node rendered by React, copy \'myRef.current\' to a variable inside the effect, and use that variable in the cleanup function.', + 'suggestions': null, + } + ], + 'options': [ + {'additionalHooks': 'useLayoutEffect_SAFE_FOR_SSR'} + ], + }, + { + // Autofix ignores constant primitives (leaving the ones that are there). + 'code': r''' + final MyComponent = uiFunction((_) { + final local1 = 42; + final local2 = '42'; + final local3 = null; + final local4 = {}; + useEffect(() { + print(local1); + print(local2); + print(local3); + print(local4); + }, [local1, local3]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'local4\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [local1, local3, local4]', + 'output': r''' + final MyComponent = uiFunction((_) { + final local1 = 42; + final local2 = '42'; + final local3 = null; + final local4 = {}; + useEffect(() { + print(local1); + print(local2); + print(local3); + print(local4); + }, [local1, local3, local4]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((_) { + useEffect(() { + window.scrollTo(0, 0); + }, [window]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has an unnecessary dependency: \'window\'. Either exclude it or remove the dependency list. Outer scope values like \'window\' aren\'t valid dependencies because mutating them doesn\'t re-render the component.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: []', + 'output': r''' + final MyComponent = uiFunction((_) { + useEffect(() { + window.scrollTo(0, 0); + }, []); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((_) { + useEffect(() { + print(MutableStore.hello); + }, [MutableStore.hello]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has an unnecessary dependency: \'MutableStore.hello\'. Either exclude it or remove the dependency list. Outer scope values like \'MutableStore.hello\' aren\'t valid dependencies because mutating them doesn\'t re-render the component.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: []', + 'output': r''' + final MyComponent = uiFunction((_) { + useEffect(() { + print(MutableStore.hello); + }, []); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + var z = {}; + final MyComponent = uiFunction((props) { + var x = props.foo; + { + var y = props.bar; + useEffect(() { + print([MutableStore.hello.world, props.foo, x, y, z, global.stuff]); + }, [MutableStore.hello.world, props.foo, x, y, z, global.stuff]); + } + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has unnecessary dependencies: \'MutableStore.hello.world\', \'global.stuff\', and \'z\'. Either exclude them or remove the dependency list. Outer scope values like \'MutableStore.hello.world\' aren\'t valid dependencies because mutating them doesn\'t re-render the component.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [props.foo, x, y]', + 'output': r''' + var z = {}; + final MyComponent = uiFunction((props) { + var x = props.foo; + { + var y = props.bar; + useEffect(() { + print([MutableStore.hello.world, props.foo, x, y, z, global.stuff]); + }, [props.foo, x, y]); + } + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + var z = {}; + final MyComponent = uiFunction((props) { + var x = props.foo; + { + var y = props.bar; + useEffect(() { + // nothing + }, [MutableStore.hello.world, props.foo, x, y, z, global.stuff]); + } + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has unnecessary dependencies: \'MutableStore.hello.world\', \'global.stuff\', and \'z\'. Either exclude them or remove the dependency list. Outer scope values like \'MutableStore.hello.world\' aren\'t valid dependencies because mutating them doesn\'t re-render the component.', + // The output should contain the ones that are inside a component + // since there are legit reasons to over-specify them for effects. + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [props.foo, x, y]', + 'output': r''' + var z = {}; + final MyComponent = uiFunction((props) { + var x = props.foo; + { + var y = props.bar; + useEffect(() { + // nothing + }, [props.foo, x, y]); + } + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + var z = {}; + final MyComponent = uiFunction((props) { + var x = props.foo; + { + var y = props.bar; + final fn = useCallback(() { + // nothing + }, [MutableStore.hello.world, props.foo, x, y, z, global.stuff]); + } + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useCallback has unnecessary dependencies: \'MutableStore.hello.world\', \'global.stuff\', \'props.foo\', \'x\', \'y\', and \'z\'. Either exclude them or remove the dependency list. Outer scope values like \'MutableStore.hello.world\' aren\'t valid dependencies because mutating them doesn\'t re-render the component.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: []', + 'output': r''' + var z = {}; + final MyComponent = uiFunction((props) { + var x = props.foo; + { + var y = props.bar; + final fn = useCallback(() { + // nothing + }, []); + } + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + var z = {}; + final MyComponent = uiFunction((props) { + var x = props.foo; + { + var y = props.bar; + final fn = useCallback(() { + // nothing + }, [MutableStore?.hello?.world, props.foo, x, y, z, global?.stuff]); + } + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useCallback has unnecessary dependencies: \'MutableStore.hello.world\', \'global.stuff\', \'props.foo\', \'x\', \'y\', and \'z\'. Either exclude them or remove the dependency list. Outer scope values like \'MutableStore.hello.world\' aren\'t valid dependencies because mutating them doesn\'t re-render the component.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: []', + 'output': r''' + var z = {}; + final MyComponent = uiFunction((props) { + var x = props.foo; + { + var y = props.bar; + final fn = useCallback(() { + // nothing + }, []); + } + }, null); + ''', + }, + ], + }, + ], + }, + { + // Every almost-static function is tainted by a dynamic value. + 'code': r''' + final MyComponent = uiFunction((props) { + var state = useState(null); + var dispatch = over_react.useReducer(null, null).dispatch; + var taint = props.foo; + handleNext1(value) { + var value2 = value * taint; + state.set(value2); + print('hello'); + } + final handleNext2 = (value) { + state.set(taint(value)); + print('hello'); + }; + var handleNext3 = (value) { + setTimeout(() => print(taint)); + dispatch({ 'type': 'x', 'value': value }); + }; + useEffect(() { + return Store.subscribe(handleNext1); + }, []); + useLayoutEffect(() { + return Store.subscribe(handleNext2); + }, []); + useMemo(() { + return Store.subscribe(handleNext3); + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'handleNext1\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [handleNext1]', + 'output': r''' + final MyComponent = uiFunction((props) { + var state = useState(null); + var dispatch = over_react.useReducer(null, null).dispatch; + var taint = props.foo; + handleNext1(value) { + var value2 = value * taint; + state.set(value2); + print('hello'); + } + final handleNext2 = (value) { + state.set(taint(value)); + print('hello'); + }; + var handleNext3 = (value) { + setTimeout(() => print(taint)); + dispatch({ 'type': 'x', 'value': value }); + }; + useEffect(() { + return Store.subscribe(handleNext1); + }, [handleNext1]); + useLayoutEffect(() { + return Store.subscribe(handleNext2); + }, []); + useMemo(() { + return Store.subscribe(handleNext3); + }, []); + }, null); + ''', + }, + ], + }, + { + 'message': + 'React Hook useLayoutEffect has a missing dependency: \'handleNext2\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [handleNext2]', + 'output': r''' + final MyComponent = uiFunction((props) { + var state = useState(null); + var dispatch = over_react.useReducer(null, null).dispatch; + var taint = props.foo; + handleNext1(value) { + var value2 = value * taint; + state.set(value2); + print('hello'); + } + final handleNext2 = (value) { + state.set(taint(value)); + print('hello'); + }; + var handleNext3 = (value) { + setTimeout(() => print(taint)); + dispatch({ 'type': 'x', 'value': value }); + }; + useEffect(() { + return Store.subscribe(handleNext1); + }, []); + useLayoutEffect(() { + return Store.subscribe(handleNext2); + }, [handleNext2]); + useMemo(() { + return Store.subscribe(handleNext3); + }, []); + }, null); + ''', + }, + ], + }, + { + 'message': + 'React Hook useMemo has a missing dependency: \'handleNext3\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [handleNext3]', + 'output': r''' + final MyComponent = uiFunction((props) { + var state = useState(null); + var dispatch = over_react.useReducer(null, null).dispatch; + var taint = props.foo; + handleNext1(value) { + var value2 = value * taint; + state.set(value2); + print('hello'); + } + final handleNext2 = (value) { + state.set(taint(value)); + print('hello'); + }; + var handleNext3 = (value) { + setTimeout(() => print(taint)); + dispatch({ 'type': 'x', 'value': value }); + }; + useEffect(() { + return Store.subscribe(handleNext1); + }, []); + useLayoutEffect(() { + return Store.subscribe(handleNext2); + }, []); + useMemo(() { + return Store.subscribe(handleNext3); + }, [handleNext3]); + }, null); + ''', + }, + ], + }, + ], + }, + { + // Regression test + 'code': r''' + final MyComponent = uiFunction((props) { + var state = useState(null); + var dispatch = over_react.useReducer(null, null).dispatch; + var taint = props.foo; + // Shouldn't affect anything + handleChange() {} + handleNext1(value) { + var value2 = value * taint; + state.set(value2); + print('hello'); + } + final handleNext2 = (value) { + state.set(taint(value)); + print('hello'); + }; + var handleNext3 = (value) { + print(taint); + dispatch({ 'type': 'x', 'value': value }); + }; + useEffect(() { + return Store.subscribe(handleNext1); + }, []); + useLayoutEffect(() { + return Store.subscribe(handleNext2); + }, []); + useMemo(() { + return Store.subscribe(handleNext3); + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'handleNext1\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [handleNext1]', + 'output': r''' + final MyComponent = uiFunction((props) { + var state = useState(null); + var dispatch = over_react.useReducer(null, null).dispatch; + var taint = props.foo; + // Shouldn't affect anything + handleChange() {} + handleNext1(value) { + var value2 = value * taint; + state.set(value2); + print('hello'); + } + final handleNext2 = (value) { + state.set(taint(value)); + print('hello'); + }; + var handleNext3 = (value) { + print(taint); + dispatch({ 'type': 'x', 'value': value }); + }; + useEffect(() { + return Store.subscribe(handleNext1); + }, [handleNext1]); + useLayoutEffect(() { + return Store.subscribe(handleNext2); + }, []); + useMemo(() { + return Store.subscribe(handleNext3); + }, []); + }, null); + ''', + }, + ], + }, + { + 'message': + 'React Hook useLayoutEffect has a missing dependency: \'handleNext2\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [handleNext2]', + 'output': r''' + final MyComponent = uiFunction((props) { + var state = useState(null); + var dispatch = over_react.useReducer(null, null).dispatch; + var taint = props.foo; + // Shouldn't affect anything + handleChange() {} + handleNext1(value) { + var value2 = value * taint; + state.set(value2); + print('hello'); + } + final handleNext2 = (value) { + state.set(taint(value)); + print('hello'); + }; + var handleNext3 = (value) { + print(taint); + dispatch({ 'type': 'x', 'value': value }); + }; + useEffect(() { + return Store.subscribe(handleNext1); + }, []); + useLayoutEffect(() { + return Store.subscribe(handleNext2); + }, [handleNext2]); + useMemo(() { + return Store.subscribe(handleNext3); + }, []); + }, null); + ''', + }, + ], + }, + { + 'message': + 'React Hook useMemo has a missing dependency: \'handleNext3\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [handleNext3]', + 'output': r''' + final MyComponent = uiFunction((props) { + var state = useState(null); + var dispatch = over_react.useReducer(null, null).dispatch; + var taint = props.foo; + // Shouldn't affect anything + handleChange() {} + handleNext1(value) { + var value2 = value * taint; + state.set(value2); + print('hello'); + } + final handleNext2 = (value) { + state.set(taint(value)); + print('hello'); + }; + var handleNext3 = (value) { + print(taint); + dispatch({ 'type': 'x', 'value': value }); + }; + useEffect(() { + return Store.subscribe(handleNext1); + }, []); + useLayoutEffect(() { + return Store.subscribe(handleNext2); + }, []); + useMemo(() { + return Store.subscribe(handleNext3); + }, [handleNext3]); + }, null); + ''', + }, + ], + }, + ], + }, + { + // Regression test + 'code': r''' + final MyComponent = uiFunction((props) { + var state = useState(null); + var dispatch = over_react.useReducer(null, null).dispatch; + var taint = props.foo; + // Shouldn't affect anything + final handleChange = () {}; + handleNext1(value) { + var value2 = value * taint; + state.set(value2); + print('hello'); + } + final handleNext2 = (value) { + state.set(taint(value)); + print('hello'); + }; + var handleNext3 = (value) { + print(taint); + dispatch({ 'type': 'x', 'value': value }); + }; + useEffect(() { + return Store.subscribe(handleNext1); + }, []); + useLayoutEffect(() { + return Store.subscribe(handleNext2); + }, []); + useMemo(() { + return Store.subscribe(handleNext3); + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'handleNext1\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [handleNext1]', + 'output': r''' + final MyComponent = uiFunction((props) { + var state = useState(null); + var dispatch = over_react.useReducer(null, null).dispatch; + var taint = props.foo; + // Shouldn't affect anything + final handleChange = () {}; + handleNext1(value) { + var value2 = value * taint; + state.set(value2); + print('hello'); + } + final handleNext2 = (value) { + state.set(taint(value)); + print('hello'); + }; + var handleNext3 = (value) { + print(taint); + dispatch({ 'type': 'x', 'value': value }); + }; + useEffect(() { + return Store.subscribe(handleNext1); + }, [handleNext1]); + useLayoutEffect(() { + return Store.subscribe(handleNext2); + }, []); + useMemo(() { + return Store.subscribe(handleNext3); + }, []); + }, null); + ''', + }, + ], + }, + { + 'message': + 'React Hook useLayoutEffect has a missing dependency: \'handleNext2\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [handleNext2]', + 'output': r''' + final MyComponent = uiFunction((props) { + var state = useState(null); + var dispatch = over_react.useReducer(null, null).dispatch; + var taint = props.foo; + // Shouldn't affect anything + final handleChange = () {}; + handleNext1(value) { + var value2 = value * taint; + state.set(value2); + print('hello'); + } + final handleNext2 = (value) { + state.set(taint(value)); + print('hello'); + }; + var handleNext3 = (value) { + print(taint); + dispatch({ 'type': 'x', 'value': value }); + }; + useEffect(() { + return Store.subscribe(handleNext1); + }, []); + useLayoutEffect(() { + return Store.subscribe(handleNext2); + }, [handleNext2]); + useMemo(() { + return Store.subscribe(handleNext3); + }, []); + }, null); + ''', + }, + ], + }, + { + 'message': + 'React Hook useMemo has a missing dependency: \'handleNext3\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [handleNext3]', + 'output': r''' + final MyComponent = uiFunction((props) { + var state = useState(null); + var dispatch = over_react.useReducer(null, null).dispatch; + var taint = props.foo; + // Shouldn't affect anything + final handleChange = () {}; + handleNext1(value) { + var value2 = value * taint; + state.set(value2); + print('hello'); + } + final handleNext2 = (value) { + state.set(taint(value)); + print('hello'); + }; + var handleNext3 = (value) { + print(taint); + dispatch({ 'type': 'x', 'value': value }); + }; + useEffect(() { + return Store.subscribe(handleNext1); + }, []); + useLayoutEffect(() { + return Store.subscribe(handleNext2); + }, []); + useMemo(() { + return Store.subscribe(handleNext3); + }, [handleNext3]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + var state = useState(null); + handleNext(value) { + state.set(value); + } + useEffect(() { + return Store.subscribe(handleNext); + }, [handleNext]); + }, null); + ''', + 'errors': [ + { + 'message': + 'The \'handleNext\' function makes the dependencies of useEffect Hook (at line 8) change on every render. Move it inside the useEffect callback. Alternatively, wrap the definition of \'handleNext\' in its own useCallback() Hook.', + // Not gonna fix a function definition + // because it's not always safe due to hoisting. + 'suggestions': null, + }, + ], + }, + { + // Even if the function only references static values, + // once you specify it in deps, it will invalidate them. + 'code': r''' + final MyComponent = uiFunction((props) { + var state = useState(null); + final handleNext = (value) { + state.set(value); + }; + useEffect(() { + return Store.subscribe(handleNext); + }, [handleNext]); + }, null); + ''', + 'errors': [ + { + 'message': + 'The \'handleNext\' function makes the dependencies of useEffect Hook (at line 8) change on every render. Move it inside the useEffect callback. Alternatively, wrap the definition of \'handleNext\' in its own useCallback() Hook.', + // We don't fix moving (too invasive). But that's the suggested fix + // when only effect uses this function. Otherwise, we'd useCallback. + 'suggestions': null, + }, + ], + }, + { + // Even if the function only references static values, + // once you specify it in deps, it will invalidate them. + // However, we can't suggest moving handleNext into the + // effect because it is *also* used outside of it. + // So our suggestion is useCallback(). + 'code': r''' + final MyComponent = uiFunction((props) { + var state = useState(null); + final handleNext = (value) { + state.set(value); + }; + useEffect(() { + return Store.subscribe(handleNext); + }, [handleNext]); + return (Dom.div()..onClick = handleNext)(); + }, null); + ''', + 'errors': [ + { + 'message': + 'The \'handleNext\' function makes the dependencies of useEffect Hook (at line 8) change on every render. To fix this, wrap the definition of \'handleNext\' in its own useCallback() Hook.', + // We fix this one with useCallback since it's + // the easy fix and you can't just move it into effect. + 'suggestions': [ + { + 'desc': 'Wrap the definition of \'handleNext\' in its own useCallback() Hook.', + 'output': r''' + final MyComponent = uiFunction((props) { + var state = useState(null); + final handleNext = useCallback((value) { + state.set(value); + }, [/* FIXME add dependencies */]); + useEffect(() { + return Store.subscribe(handleNext); + }, [handleNext]); + return (Dom.div()..onClick = handleNext)(); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + handleNext1() { + print('hello'); + } + final handleNext2 = () { + print('hello'); + }; + var handleNext3 = () { + print('hello'); + }; + useEffect(() { + return Store.subscribe(handleNext1); + }, [handleNext1]); + useLayoutEffect(() { + return Store.subscribe(handleNext2); + }, [handleNext2]); + useMemo(() { + return Store.subscribe(handleNext3); + }, [handleNext3]); + }, null); + ''', + 'errors': [ + { + 'message': + 'The \'handleNext1\' function makes the dependencies of useEffect Hook (at line 13) change on every render. Move it inside the useEffect callback. Alternatively, wrap the definition of \'handleNext1\' in its own useCallback() Hook.', + 'suggestions': null, + }, + { + 'message': + 'The \'handleNext2\' function makes the dependencies of useLayoutEffect Hook (at line 16) change on every render. Move it inside the useLayoutEffect callback. Alternatively, wrap the definition of \'handleNext2\' in its own useCallback() Hook.', + 'suggestions': null, + }, + { + 'message': + 'The \'handleNext3\' function makes the dependencies of useMemo Hook (at line 19) change on every render. Move it inside the useMemo callback. Alternatively, wrap the definition of \'handleNext3\' in its own useCallback() Hook.', + 'suggestions': null, + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + handleNext1() { + print('hello'); + } + final handleNext2 = () { + print('hello'); + }; + var handleNext3 = () { + print('hello'); + }; + useEffect(() { + handleNext1(); + return Store.subscribe(() => handleNext1()); + }, [handleNext1]); + useLayoutEffect(() { + handleNext2(); + return Store.subscribe(() => handleNext2()); + }, [handleNext2]); + useMemo(() { + handleNext3(); + return Store.subscribe(() => handleNext3()); + }, [handleNext3]); + }, null); + ''', + // Suggestions don't wrap into useCallback here + // because they are only referenced by effect itself. + 'errors': [ + { + 'message': + 'The \'handleNext1\' function makes the dependencies of useEffect Hook (at line 14) change on every render. Move it inside the useEffect callback. Alternatively, wrap the definition of \'handleNext1\' in its own useCallback() Hook.', + 'suggestions': null, + }, + { + 'message': + 'The \'handleNext2\' function makes the dependencies of useLayoutEffect Hook (at line 18) change on every render. Move it inside the useLayoutEffect callback. Alternatively, wrap the definition of \'handleNext2\' in its own useCallback() Hook.', + 'suggestions': null, + }, + { + 'message': + 'The \'handleNext3\' function makes the dependencies of useMemo Hook (at line 22) change on every render. Move it inside the useMemo callback. Alternatively, wrap the definition of \'handleNext3\' in its own useCallback() Hook.', + 'suggestions': null, + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + handleNext1() { + print('hello'); + } + final handleNext2 = () { + print('hello'); + }; + var handleNext3 = () { + print('hello'); + }; + useEffect(() { + handleNext1(); + return Store.subscribe(() => handleNext1()); + }, [handleNext1]); + useLayoutEffect(() { + handleNext2(); + return Store.subscribe(() => handleNext2()); + }, [handleNext2]); + useMemo(() { + handleNext3(); + return Store.subscribe(() => handleNext3()); + }, [handleNext3]); + return ( + (Dom.div() + ..onClick = (_) { + handleNext1(); + setTimeout(handleNext2); + setTimeout(() { + handleNext3(); + }); + } + )() + ); + }, null); + ''', + 'errors': [ + { + 'message': + 'The \'handleNext1\' function makes the dependencies of useEffect Hook (at line 14) change on every render. To fix this, wrap the definition of \'handleNext1\' in its own useCallback() Hook.', + // Suggestion wraps into useCallback where possible + // because they are also referenced outside the effect. + 'suggestions': [ + { + 'desc': 'Wrap the definition of \'handleNext1\' in its own useCallback() Hook.', + 'output': r''' + final MyComponent = uiFunction((props) { + final handleNext1 = useCallback(() { + print('hello'); + }, [/* FIXME add dependencies */]); + final handleNext2 = () { + print('hello'); + }; + var handleNext3 = () { + print('hello'); + }; + useEffect(() { + handleNext1(); + return Store.subscribe(() => handleNext1()); + }, [handleNext1]); + useLayoutEffect(() { + handleNext2(); + return Store.subscribe(() => handleNext2()); + }, [handleNext2]); + useMemo(() { + handleNext3(); + return Store.subscribe(() => handleNext3()); + }, [handleNext3]); + return ( + (Dom.div() + ..onClick = (_) { + handleNext1(); + setTimeout(handleNext2); + setTimeout(() { + handleNext3(); + }); + } + )() + ); + }, null); + ''', + }, + ], + }, + { + 'message': + 'The \'handleNext2\' function makes the dependencies of useLayoutEffect Hook (at line 18) change on every render. To fix this, wrap the definition of \'handleNext2\' in its own useCallback() Hook.', + // Suggestion wraps into useCallback where possible + // because they are also referenced outside the effect. + 'suggestions': [ + { + 'desc': 'Wrap the definition of \'handleNext2\' in its own useCallback() Hook.', + 'output': r''' + final MyComponent = uiFunction((props) { + handleNext1() { + print('hello'); + } + final handleNext2 = useCallback(() { + print('hello'); + }, [/* FIXME add dependencies */]); + var handleNext3 = () { + print('hello'); + }; + useEffect(() { + handleNext1(); + return Store.subscribe(() => handleNext1()); + }, [handleNext1]); + useLayoutEffect(() { + handleNext2(); + return Store.subscribe(() => handleNext2()); + }, [handleNext2]); + useMemo(() { + handleNext3(); + return Store.subscribe(() => handleNext3()); + }, [handleNext3]); + return ( + (Dom.div() + ..onClick = (_) { + handleNext1(); + setTimeout(handleNext2); + setTimeout(() { + handleNext3(); + }); + } + )() + ); + }, null); + ''', + }, + ], + }, + { + 'message': + 'The \'handleNext3\' function makes the dependencies of useMemo Hook (at line 22) change on every render. To fix this, wrap the definition of \'handleNext3\' in its own useCallback() Hook.', + // Autofix wraps into useCallback where possible + // because they are also referenced outside the effect. + 'suggestions': [ + { + 'desc': 'Wrap the definition of \'handleNext3\' in its own useCallback() Hook.', + 'output': r''' + final MyComponent = uiFunction((props) { + handleNext1() { + print('hello'); + } + final handleNext2 = () { + print('hello'); + }; + var handleNext3 = useCallback(() { + print('hello'); + }, [/* FIXME add dependencies */]); + useEffect(() { + handleNext1(); + return Store.subscribe(() => handleNext1()); + }, [handleNext1]); + useLayoutEffect(() { + handleNext2(); + return Store.subscribe(() => handleNext2()); + }, [handleNext2]); + useMemo(() { + handleNext3(); + return Store.subscribe(() => handleNext3()); + }, [handleNext3]); + return ( + (Dom.div() + ..onClick = (_) { + handleNext1(); + setTimeout(handleNext2); + setTimeout(() { + handleNext3(); + }); + } + )() + ); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + final handleNext1 = () { + print('hello'); + }; + handleNext2() { + print('hello'); + } + useEffect(() { + return Store.subscribe(handleNext1); + return Store.subscribe(handleNext2); + }, [handleNext1, handleNext2]); + useEffect(() { + return Store.subscribe(handleNext1); + return Store.subscribe(handleNext2); + }, [handleNext1, handleNext2]); + }, null); + ''', + // Normally we'd suggest moving handleNext inside an + // effect. But it's used by more than one. So we + // suggest useCallback() and use it for the autofix + // where possible. + // TODO(ported):we could coalesce messages for the same function if it affects multiple Hooks. + 'errors': [ + { + 'message': + 'The \'handleNext1\' function makes the dependencies of useEffect Hook (at line 11) change on every render. To fix this, wrap the definition of \'handleNext1\' in its own useCallback() Hook.', + 'suggestions': [ + { + 'desc': 'Wrap the definition of \'handleNext1\' in its own useCallback() Hook.', + 'output': r''' + final MyComponent = uiFunction((props) { + final handleNext1 = useCallback(() { + print('hello'); + }, [/* FIXME add dependencies */]); + handleNext2() { + print('hello'); + } + useEffect(() { + return Store.subscribe(handleNext1); + return Store.subscribe(handleNext2); + }, [handleNext1, handleNext2]); + useEffect(() { + return Store.subscribe(handleNext1); + return Store.subscribe(handleNext2); + }, [handleNext1, handleNext2]); + }, null); + ''', + }, + ], + }, + { + 'message': + 'The \'handleNext1\' function makes the dependencies of useEffect Hook (at line 15) change on every render. To fix this, wrap the definition of \'handleNext1\' in its own useCallback() Hook.', + 'suggestions': [ + { + 'desc': 'Wrap the definition of \'handleNext1\' in its own useCallback() Hook.', + 'output': r''' + final MyComponent = uiFunction((props) { + final handleNext1 = useCallback(() { + print('hello'); + }, [/* FIXME add dependencies */]); + handleNext2() { + print('hello'); + } + useEffect(() { + return Store.subscribe(handleNext1); + return Store.subscribe(handleNext2); + }, [handleNext1, handleNext2]); + useEffect(() { + return Store.subscribe(handleNext1); + return Store.subscribe(handleNext2); + }, [handleNext1, handleNext2]); + }, null); + ''', + }, + ], + }, + { + 'message': + 'The \'handleNext2\' function makes the dependencies of useEffect Hook (at line 11) change on every render. To fix this, wrap the definition of \'handleNext2\' in its own useCallback() Hook.', + 'suggestions': [ + { + 'desc': 'Wrap the definition of \'handleNext2\' in its own useCallback() Hook.', + 'output': r''' + final MyComponent = uiFunction((props) { + final handleNext1 = () { + print('hello'); + }; + final handleNext2 = useCallback(() { + print('hello'); + }, [/* FIXME add dependencies */]); + useEffect(() { + return Store.subscribe(handleNext1); + return Store.subscribe(handleNext2); + }, [handleNext1, handleNext2]); + useEffect(() { + return Store.subscribe(handleNext1); + return Store.subscribe(handleNext2); + }, [handleNext1, handleNext2]); + }, null); + ''', + }, + ] + }, + { + 'message': + 'The \'handleNext2\' function makes the dependencies of useEffect Hook (at line 15) change on every render. To fix this, wrap the definition of \'handleNext2\' in its own useCallback() Hook.', + 'suggestions': [ + { + 'desc': 'Wrap the definition of \'handleNext2\' in its own useCallback() Hook.', + 'output': r''' + final MyComponent = uiFunction((props) { + final handleNext1 = () { + print('hello'); + }; + final handleNext2 = useCallback(() { + print('hello'); + }, [/* FIXME add dependencies */]); + useEffect(() { + return Store.subscribe(handleNext1); + return Store.subscribe(handleNext2); + }, [handleNext1, handleNext2]); + useEffect(() { + return Store.subscribe(handleNext1); + return Store.subscribe(handleNext2); + }, [handleNext1, handleNext2]); + }, null); + ''', + }, + ] + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + var handleNext = () { + print('hello'); + }; + if (props.foo) { + handleNext = () { + print('hello'); + }; + } + useEffect(() { + return Store.subscribe(handleNext); + }, [handleNext]); + }, null); + ''', + 'errors': [ + { + 'message': + 'The \'handleNext\' function makes the dependencies of useEffect Hook (at line 12) change on every render. To fix this, wrap the definition of \'handleNext\' in its own useCallback() Hook.', + // Normally we'd suggest moving handleNext inside an + // effect. But it's used more than once. + // TODO(ported):our autofix here isn't quite sufficient because + // it only wraps the first definition. But seems ok. + 'suggestions': [ + { + 'desc': 'Wrap the definition of \'handleNext\' in its own useCallback() Hook.', + 'output': r''' + final MyComponent = uiFunction((props) { + var handleNext = useCallback(() { + print('hello'); + }, [/* FIXME add dependencies */]); + if (props.foo) { + handleNext = () { + print('hello'); + }; + } + useEffect(() { + return Store.subscribe(handleNext); + }, [handleNext]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + var state = useState(null); + var taint = props.foo; + handleNext(value) { + var value2 = value * taint; + state.set(value2); + print('hello'); + } + useEffect(() { + return Store.subscribe(handleNext); + }, [handleNext]); + }, null); + ''', + 'errors': [ + { + 'message': + 'The \'handleNext\' function makes the dependencies of useEffect Hook (at line 11) change on every render. Move it inside the useEffect callback. Alternatively, wrap the definition of \'handleNext\' in its own useCallback() Hook.', + 'suggestions': null, + }, + ], + }, + { + 'code': r''' + final Counter = uiFunction((_) { + var count = useState(0); + useEffect(() { + var id = setInterval(() { + count.set(count.value + 1); + }, 1000); + return () => clearInterval(id); + }, []); + return Dom.h1()(count.value); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'count.value\'. Either include it or remove the dependency list. You can also do a functional update \'count.setWithUpdater((c) => ...)\' if you only need \'count.value\' in the \'count.set\' call.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [count.value]', + 'output': r''' + final Counter = uiFunction((_) { + var count = useState(0); + useEffect(() { + var id = setInterval(() { + count.set(count.value + 1); + }, 1000); + return () => clearInterval(id); + }, [count.value]); + return Dom.h1()(count.value); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final Counter = uiFunction((_) { + var count = useState(0); + var increment = useState(0); + useEffect(() { + var id = setInterval(() { + count.set(count.value + increment.value); + }, 1000); + return () => clearInterval(id); + }, []); + return Dom.h1()(count.value); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has missing dependencies: \'count.value\' and \'increment.value\'. Either include them or remove the dependency list. You can also do a functional update \'count.setWithUpdater((c) => ...)\' if you only need \'count.value\' in the \'count.set\' call.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [count.value, increment.value]', + 'output': r''' + final Counter = uiFunction((_) { + var count = useState(0); + var increment = useState(0); + useEffect(() { + var id = setInterval(() { + count.set(count.value + increment.value); + }, 1000); + return () => clearInterval(id); + }, [count.value, increment.value]); + return Dom.h1()(count.value); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final Counter = uiFunction((_) { + var count = useState(0); + var increment = useState(0); + useEffect(() { + var id = setInterval(() { + count.setWithUpdater((value) => value + increment.value); + }, 1000); + return () => clearInterval(id); + }, []); + return Dom.h1()(count.value); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'increment.value\'. Either include it or remove the dependency list. You can also replace multiple useState variables with useReducer if \'count.setWithUpdater\' needs \'increment.value\'.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [increment.value]', + 'output': r''' + final Counter = uiFunction((_) { + var count = useState(0); + var increment = useState(0); + useEffect(() { + var id = setInterval(() { + count.setWithUpdater((value) => value + increment.value); + }, 1000); + return () => clearInterval(id); + }, [increment.value]); + return Dom.h1()(count.value); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + dynamic useCustomHook() => null; + final Counter = uiFunction((_) { + var count = useState(0); + var increment = useCustomHook(); + useEffect(() { + var id = setInterval(() { + count.setWithUpdater((value) => value + increment); + }, 1000); + return () => clearInterval(id); + }, []); + return Dom.h1()(count.value); + }, null); + ''', + // This intentionally doesn't show the reducer message + // because we don't know if it's safe for it to close over a value. + // We only show it for state variables (and possibly props). + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'increment\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [increment]', + 'output': r''' + dynamic useCustomHook() => null; + final Counter = uiFunction((_) { + var count = useState(0); + var increment = useCustomHook(); + useEffect(() { + var id = setInterval(() { + count.setWithUpdater((value) => value + increment); + }, 1000); + return () => clearInterval(id); + }, [increment]); + return Dom.h1()(count.value); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final Counter = uiFunction((props) { + var step = props.step; + + var count = useState(0); + int increment(int x) { + return x + step; + } + useEffect(() { + var id = setInterval(() { + count.setWithUpdater((value) => increment(value)); + }, 1000); + return () => clearInterval(id); + }, []); + return Dom.h1()(count.value); + }, null); + ''', + // This intentionally doesn't show the reducer message + // because we don't know if it's safe for it to close over a value. + // We only show it for state variables (and possibly props). + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'increment\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [increment]', + 'output': r''' + final Counter = uiFunction((props) { + var step = props.step; + + var count = useState(0); + int increment(int x) { + return x + step; + } + useEffect(() { + var id = setInterval(() { + count.setWithUpdater((value) => increment(value)); + }, 1000); + return () => clearInterval(id); + }, [increment]); + return Dom.h1()(count.value); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final Counter = uiFunction((props) { + var step = props.step; + + var count = useState(0); + int increment(int x) { + return x + step; + } + useEffect(() { + var id = setInterval(() { + count.setWithUpdater((value) => increment(value)); + }, 1000); + return () => clearInterval(id); + }, [increment]); + return Dom.h1()(count.value); + }, null); + ''', + 'errors': [ + { + 'message': + 'The \'increment\' function makes the dependencies of useEffect Hook (at line 13) change on every render. Move it inside the useEffect callback. Alternatively, wrap the definition of \'increment\' in its own useCallback() Hook.', + 'suggestions': null, + }, + ], + }, + { + 'name': 'Missing prop dependency used in state setter (prop variable)', + 'code': r''' + final Counter = uiFunction((props) { + final increment = props.increment; + + var count = useState(0); + useEffect(() { + var id = setInterval(() { + count.setWithUpdater((value) => value + increment); + }, 1000); + return () => clearInterval(id); + }, []); + return Dom.h1()(count.value); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'increment\'. Either include it or remove the dependency list. If \'count.setWithUpdater\' needs the current value of \'increment\', you can also switch to useReducer instead of useState and read \'increment\' in the reducer.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [increment]', + 'output': r''' + final Counter = uiFunction((props) { + final increment = props.increment; + + var count = useState(0); + useEffect(() { + var id = setInterval(() { + count.setWithUpdater((value) => value + increment); + }, 1000); + return () => clearInterval(id); + }, [increment]); + return Dom.h1()(count.value); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'name': 'Missing prop dependency used in state setter (accessed via `props.`)', + 'code': r''' + final Counter = uiFunction((props) { + var count = useState(0); + useEffect(() { + var id = setInterval(() { + count.setWithUpdater((value) => value + props.increment); + }, 1000); + return () => clearInterval(id); + }, []); + return Dom.h1()(count.value); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'props.increment\'. Either include it or remove the dependency list. If \'count.setWithUpdater\' needs the current value of \'props.increment\', you can also switch to useReducer instead of useState and read \'props.increment\' in the reducer.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [props.increment]', + 'output': r''' + final Counter = uiFunction((props) { + var count = useState(0); + useEffect(() { + var id = setInterval(() { + count.setWithUpdater((value) => value + props.increment); + }, 1000); + return () => clearInterval(id); + }, [props.increment]); + return Dom.h1()(count.value); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final Counter = uiFunction((_) { + var count = useState(0); + tick() { + count.set(count.value + 1); + } + useEffect(() { + var id = setInterval(() { + tick(); + }, 1000); + return () => clearInterval(id); + }, []); + return Dom.h1()(count.value); + }, null); + ''', + // TODO(ported):ideally this should suggest useState updater form + // since this code doesn't actually work. The autofix could + // at least avoid suggesting 'tick' since it's obviously + // always different, and thus useless. + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'tick\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [tick]', + 'output': r''' + final Counter = uiFunction((_) { + var count = useState(0); + tick() { + count.set(count.value + 1); + } + useEffect(() { + var id = setInterval(() { + tick(); + }, 1000); + return () => clearInterval(id); + }, [tick]); + return Dom.h1()(count.value); + }, null); + ''', + }, + ], + }, + ], + }, + /* ("Regression test for a crash" previously here was removed since the original cause of the crash is not applicable + in the Dart logic, and the test case involving variables declared after they're referenced is not valid in Dart.) */ + { + 'code': r''' + final Podcasts = uiFunction((props) { + var fetchPodcasts = props.fetchPodcasts; + var id = props.id; + + var podcasts = useState(null); + useEffect(() { + fetchPodcasts(id).then(podcasts.set); + }, [id]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'fetchPodcasts\'. Either include it or remove the dependency list. If \'fetchPodcasts\' changes too often, find the parent component that defines it and wrap that definition in useCallback.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [fetchPodcasts, id]', + 'output': r''' + final Podcasts = uiFunction((props) { + var fetchPodcasts = props.fetchPodcasts; + var id = props.id; + + var podcasts = useState(null); + useEffect(() { + fetchPodcasts(id).then(podcasts.set); + }, [fetchPodcasts, id]); + }, null); + ''', + }, + ], + }, + ], + }, + // This test case was added to verify the same behavior for direct invocations of function props, + // since they're treated differently in the Dart implementation of this lint. + { + 'code': r''' + final Podcasts = uiFunction((props) { + var id = props.id; + + var podcasts = useState(null); + useEffect(() { + props.fetchPodcasts(id).then(podcasts.set); + }, [id]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'props.fetchPodcasts\'. Either include it or remove the dependency list. If \'props.fetchPodcasts\' changes too often, find the parent component that defines it and wrap that definition in useCallback.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [id, props.fetchPodcasts]', + 'output': r''' + final Podcasts = uiFunction((props) { + var id = props.id; + + var podcasts = useState(null); + useEffect(() { + props.fetchPodcasts(id).then(podcasts.set); + }, [id, props.fetchPodcasts]); + }, null); + ''', + }, + ], + }, + ], + }, + /* (1 case previously here was removed, since it was the same as the above test, only props were destructured in the argument list) */ + { + 'code': r''' + final Podcasts = uiFunction((props) { + var fetchPodcasts = props.fetchPodcasts; + var fetchPodcasts2 = props.fetchPodcasts2; + var id = props.id; + + var podcasts = useState(null); + useEffect(() { + setTimeout(() { + print(id); + fetchPodcasts(id).then(podcasts.set); + fetchPodcasts2(id).then(podcasts.set); + }); + }, [id]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has missing dependencies: \'fetchPodcasts\' and \'fetchPodcasts2\'. Either include them or remove the dependency list. If any of \'fetchPodcasts\' or \'fetchPodcasts2\' changes too often, find the parent component that defines it and wrap that definition in useCallback.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [fetchPodcasts, fetchPodcasts2, id]', + 'output': r''' + final Podcasts = uiFunction((props) { + var fetchPodcasts = props.fetchPodcasts; + var fetchPodcasts2 = props.fetchPodcasts2; + var id = props.id; + + var podcasts = useState(null); + useEffect(() { + setTimeout(() { + print(id); + fetchPodcasts(id).then(podcasts.set); + fetchPodcasts2(id).then(podcasts.set); + }); + }, [fetchPodcasts, fetchPodcasts2, id]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final Podcasts = uiFunction((props) { + var fetchPodcasts = props.fetchPodcasts; + var id = props.id; + + var podcasts = useState(null); + useEffect(() { + print(fetchPodcasts); + fetchPodcasts(id).then(podcasts.set); + }, [id]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'fetchPodcasts\'. Either include it or remove the dependency list. If \'fetchPodcasts\' changes too often, find the parent component that defines it and wrap that definition in useCallback.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [fetchPodcasts, id]', + 'output': r''' + final Podcasts = uiFunction((props) { + var fetchPodcasts = props.fetchPodcasts; + var id = props.id; + + var podcasts = useState(null); + useEffect(() { + print(fetchPodcasts); + fetchPodcasts(id).then(podcasts.set); + }, [fetchPodcasts, id]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final Podcasts = uiFunction((props) { + var fetchPodcasts = props.fetchPodcasts; + var id = props.id; + + var podcasts = useState(null); + useEffect(() { + print(fetchPodcasts); + fetchPodcasts?.call(id).then(podcasts.set); + }, [id]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'fetchPodcasts\'. Either include it or remove the dependency list. If \'fetchPodcasts\' changes too often, find the parent component that defines it and wrap that definition in useCallback.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [fetchPodcasts, id]', + 'output': r''' + final Podcasts = uiFunction((props) { + var fetchPodcasts = props.fetchPodcasts; + var id = props.id; + + var podcasts = useState(null); + useEffect(() { + print(fetchPodcasts); + fetchPodcasts?.call(id).then(podcasts.set); + }, [fetchPodcasts, id]); + }, null); + ''', + }, + ], + }, + ], + }, + { + // The mistake here is that it was moved inside the effect + // so it can't be referenced in the deps array. + 'code': r''' + final Thing = uiFunction((_) { + useEffect(() { + final fetchData = () async {}; + fetchData(); + // This case specifically involves an undefined name as a result of + // moving a function (fetchData) into callback. + // ignore: undefined_identifier + }, [fetchData]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has an unnecessary dependency: \'fetchData\'. Either exclude it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: []', + 'output': r''' + final Thing = uiFunction((_) { + useEffect(() { + final fetchData = () async {}; + fetchData(); + // This case specifically involves an undefined name as a result of + // moving a function (fetchData) into callback. + // ignore: undefined_identifier + }, []); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final Hello = uiFunction((_) { + var state = useState(0); + // In JS, dependencies are optional. In Dart, they're required for only useCallback (this is likely an oversight). + // ignore: not_enough_positional_arguments + useEffect(() { + state.set(1); + }); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect contains a call to \'state.set\'. Without a list of dependencies, this can lead to an infinite chain of updates. To fix this, pass [] as a second argument to the useEffect Hook.', + 'suggestions': [ + { + 'desc': 'Add dependencies list: []', + 'output': r''' + final Hello = uiFunction((_) { + var state = useState(0); + // In JS, dependencies are optional. In Dart, they're required for only useCallback (this is likely an oversight). + // ignore: not_enough_positional_arguments + useEffect(() { + state.set(1); + }, []); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + Future fetchDataFuture; + final Hello = uiFunction((_) { + var data = useState(0); + useEffect(() { + fetchDataFuture.then(data.set); + }); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect contains a call to \'data.set\'. Without a list of dependencies, this can lead to an infinite chain of updates. To fix this, pass [] as a second argument to the useEffect Hook.', + 'suggestions': [ + { + 'desc': 'Add dependencies list: []', + 'output': r''' + Future fetchDataFuture; + final Hello = uiFunction((_) { + var data = useState(0); + useEffect(() { + fetchDataFuture.then(data.set); + }, []); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + Function fetchData; + final Hello = uiFunction((props) { + var country = props.country; + var data = useState(0); + // In JS, dependencies are optional. In Dart, they're required for only useCallback (this is likely an oversight). + // ignore: not_enough_positional_arguments + useEffect(() { + fetchData(country).then(data.set); + }); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect contains a call to \'data.set\'. Without a list of dependencies, this can lead to an infinite chain of updates. To fix this, pass [country] as a second argument to the useEffect Hook.', + 'suggestions': [ + { + 'desc': 'Add dependencies list: [country]', + 'output': r''' + Function fetchData; + final Hello = uiFunction((props) { + var country = props.country; + var data = useState(0); + // In JS, dependencies are optional. In Dart, they're required for only useCallback (this is likely an oversight). + // ignore: not_enough_positional_arguments + useEffect(() { + fetchData(country).then(data.set); + }, [country]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final Hello = uiFunction((props) { + var prop1 = props.prop1; + var prop2 = props.prop2; + + var state = useState(0); + useEffect(() { + if (prop1) { + state.set(prop2); + } + }); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect contains a call to \'state.set\'. Without a list of dependencies, this can lead to an infinite chain of updates. To fix this, pass [prop1, prop2] as a second argument to the useEffect Hook.', + 'suggestions': [ + { + 'desc': 'Add dependencies list: [prop1, prop2]', + 'output': r''' + final Hello = uiFunction((props) { + var prop1 = props.prop1; + var prop2 = props.prop2; + + var state = useState(0); + useEffect(() { + if (prop1) { + state.set(prop2); + } + }, [prop1, prop2]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final Thing = uiFunction((_) { + useEffect(() async {}, []); + }, null); + ''', + 'errors': [ + { + 'message': "" + "Effect callbacks are synchronous to prevent race conditions. Put the async function inside:\n" + "\n" + "useEffect(() {\n" + " fetchData() async {\n" + " // You can await here\n" + " final response = await myAPI.getData(someId);\n" + " // ...\n" + " }\n" + " fetchData();\n" + "}, [someId]); // Or [] if effect doesn't need props or state\n" + "\n" + "Learn more about data fetching with Hooks: https://reactjs.org/link/hooks-data-fetching", + 'suggestions': null, + }, + ], + }, + { + 'code': r''' + final Thing = uiFunction((_) { + useEffect(() async {}); + }, null); + ''', + 'errors': [ + { + 'message': "" + "Effect callbacks are synchronous to prevent race conditions. Put the async function inside:\n" + "\n" + "useEffect(() {\n" + " fetchData() async {\n" + " // You can await here\n" + " final response = await myAPI.getData(someId);\n" + " // ...\n" + " }\n" + " fetchData();\n" + "}, [someId]); // Or [] if effect doesn't need props or state\n" + "\n" + "Learn more about data fetching with Hooks: https://reactjs.org/link/hooks-data-fetching", + 'suggestions': null, + }, + ], + }, + /* 2 test cases previously was removed involving referencing variables inside their initializers, since that it not allowed in Dart */ + { + 'code': r''' + final MyComponent = uiFunction((_) { + final local = {}; + myEffect() { + print(local); + } + useEffect(myEffect, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'local\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [local]', + 'output': r''' + final MyComponent = uiFunction((_) { + final local = {}; + myEffect() { + print(local); + } + useEffect(myEffect, [local]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((_) { + final local = {}; + final myEffect = () { + print(local); + }; + useEffect(myEffect, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'local\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [local]', + 'output': r''' + final MyComponent = uiFunction((_) { + final local = {}; + final myEffect = () { + print(local); + }; + useEffect(myEffect, [local]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((_) { + final local = {}; + final myEffect = () { + print(local); + }; + useEffect(myEffect, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'local\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [local]', + 'output': r''' + final MyComponent = uiFunction((_) { + final local = {}; + final myEffect = () { + print(local); + }; + useEffect(myEffect, [local]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((_) { + final local = {}; + final otherThing = () { + print(local); + }; + final myEffect = () { + otherThing(); + }; + useEffect(myEffect, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'otherThing\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [otherThing]', + 'output': r''' + final MyComponent = uiFunction((_) { + final local = {}; + final otherThing = () { + print(local); + }; + final myEffect = () { + otherThing(); + }; + useEffect(myEffect, [otherThing]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + num delay; + final MyComponent = uiFunction((_) { + final local = {}; + final myEffect = debounce(() { + print(local); + }, delay); + useEffect(myEffect, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'myEffect\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [myEffect]', + 'output': r''' + num delay; + final MyComponent = uiFunction((_) { + final local = {}; + final myEffect = debounce(() { + print(local); + }, delay); + useEffect(myEffect, [myEffect]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + num delay; + final MyComponent = uiFunction((_) { + final local = {}; + final myEffect = debounce(() { + print(local); + }, delay); + useEffect(myEffect, [local]); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'myEffect\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [myEffect]', + 'output': r''' + num delay; + final MyComponent = uiFunction((_) { + final local = {}; + final myEffect = debounce(() { + print(local); + }, delay); + useEffect(myEffect, [myEffect]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((props) { + var myEffect = props.myEffect; + + useEffect(myEffect, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'myEffect\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [myEffect]', + 'output': r''' + final MyComponent = uiFunction((props) { + var myEffect = props.myEffect; + + useEffect(myEffect, [myEffect]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + num delay; + final MyComponent = uiFunction((_) { + final local = {}; + useEffect(debounce(() { + print(local); + }, delay), []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect received a function whose dependencies are unknown. Pass an inline function instead.', + 'suggestions': [], + }, + ], + }, + /* 1 test case previously here was removed around enableDangerousAutofixThisMayCauseInfiniteLoops, which is not applicable in this implementation */ + { + 'code': r''' + final MyComponent = uiFunction((props) { + dynamic foo; + useEffect(() { + foo.bar.baz = 43; + props.foo.bar.baz = 1; + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has missing dependencies: \'foo.bar\' and \'props.foo.bar\'. Either include them or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [foo.bar, props.foo.bar]', + 'output': r''' + final MyComponent = uiFunction((props) { + dynamic foo; + useEffect(() { + foo.bar.baz = 43; + props.foo.bar.baz = 1; + }, [foo.bar, props.foo.bar]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final Component = uiFunction((_) { + final foo = {}; + useMemo(() => foo, [foo]); + }, null); + ''', + 'errors': [ + { + 'message': + 'The \'foo\' Map makes the dependencies of useMemo Hook (at line 3) change on every render. Move it inside the useMemo callback. Alternatively, wrap the initialization of \'foo\' in its own useMemo() Hook.', + 'suggestions': null, + }, + ], + }, + /* (2 cases previously here involving var/let were consolidated into a single case below) */ + { + 'code': r''' + final Component = uiFunction((_) { + final foo = []; + useMemo(() => foo, [foo]); + }, null); + ''', + 'errors': [ + { + 'message': + 'The \'foo\' List makes the dependencies of useMemo Hook (at line 3) change on every render. Move it inside the useMemo callback. Alternatively, wrap the initialization of \'foo\' in its own useMemo() Hook.', + 'suggestions': null, + }, + ], + }, + /* (1 case previously here involving named functions was removed, since there is no equivalent in Dart) */ + /* (1 case previously here involving class expressions was removed, since there is no equivalent in Dart) */ + { + 'code': r''' + final Component = uiFunction((_) { + final foo = true ? {} : "fine"; + useMemo(() => foo, [foo]); + }, null); + ''', + 'errors': [ + { + 'message': + 'The \'foo\' conditional could make the dependencies of useMemo Hook (at line 3) change on every render. Move it inside the useMemo callback. Alternatively, wrap the initialization of \'foo\' in its own useMemo() Hook.', + 'suggestions': null, + }, + ], + }, + /* (1 case previously here involving || operator on non-booleans was removed, since there is no equivalent in Dart) */ + { + 'code': r''' + final Component = uiFunction((_) { + dynamic bar; + final foo = bar ?? {}; + useMemo(() => foo, [foo]); + }, null); + ''', + 'errors': [ + { + 'message': + 'The \'foo\' binary expression could make the dependencies of useMemo Hook (at line 4) change on every render. Move it inside the useMemo callback. Alternatively, wrap the initialization of \'foo\' in its own useMemo() Hook.', + 'suggestions': null, + }, + ], + }, + /* (1 case previously here involving && operator on non-booleans was removed, since there is no equivalent in Dart) */ + { + 'code': r''' + final Component = uiFunction((_) { + dynamic bar; + dynamic baz; + final foo = bar ? baz ? {} : null : null; + useMemo(() => foo, [foo]); + }, null); + ''', + 'errors': [ + { + 'message': + 'The \'foo\' conditional could make the dependencies of useMemo Hook (at line 5) change on every render. Move it inside the useMemo callback. Alternatively, wrap the initialization of \'foo\' in its own useMemo() Hook.', + 'suggestions': null, + }, + ], + }, + /* (2 cases previously here involving var/let were consolidated into a single case below) */ + { + 'code': r''' + final Component = uiFunction((_) { + var foo = {}; + useMemo(() => foo, [foo]); + }, null); + ''', + 'errors': [ + { + 'message': + 'The \'foo\' Map makes the dependencies of useMemo Hook (at line 3) change on every render. Move it inside the useMemo callback. Alternatively, wrap the initialization of \'foo\' in its own useMemo() Hook.', + 'suggestions': null, + }, + ], + }, + { + 'code': r''' + final Component = uiFunction((_) { + final foo = {}; + useCallback(() { + print(foo); + }, [foo]); + }, null); + ''', + 'errors': [ + { + 'message': + 'The \'foo\' Map makes the dependencies of useCallback Hook (at line 5) change on every render. Move it inside the useCallback callback. Alternatively, wrap the initialization of \'foo\' in its own useMemo() Hook.', + 'suggestions': null, + }, + ], + }, + { + 'code': r''' + final Component = uiFunction((_) { + final foo = {}; + useEffect(() { + print(foo); + }, [foo]); + }, null); + ''', + 'errors': [ + { + 'message': + 'The \'foo\' Map makes the dependencies of useEffect Hook (at line 5) change on every render. Move it inside the useEffect callback. Alternatively, wrap the initialization of \'foo\' in its own useMemo() Hook.', + 'suggestions': null, + }, + ], + }, + { + 'code': r''' + final Component = uiFunction((_) { + final foo = {}; + useLayoutEffect(() { + print(foo); + }, [foo]); + }, null); + ''', + 'errors': [ + { + 'message': + 'The \'foo\' Map makes the dependencies of useLayoutEffect Hook (at line 5) change on every render. Move it inside the useLayoutEffect callback. Alternatively, wrap the initialization of \'foo\' in its own useMemo() Hook.', + 'suggestions': null, + }, + ], + }, + { + 'code': r''' + final Component = uiFunction((_) { + final foo = {}; + useImperativeHandle( + {}, + () { + print(foo); + }, + [foo] + ); + }, null); + ''', + 'errors': [ + { + 'message': + 'The \'foo\' Map makes the dependencies of useImperativeHandle Hook (at line 8) change on every render. Move it inside the useImperativeHandle callback. Alternatively, wrap the initialization of \'foo\' in its own useMemo() Hook.', + 'suggestions': null, + }, + ], + }, + { + 'code': r''' + final Foo = uiFunction((props) { + final foo = props.section_components?.edges ?? []; + useEffect(() { + print(foo); + }, [foo]); + }, null); + ''', + 'errors': [ + { + 'message': + 'The \'foo\' binary expression could make the dependencies of useEffect Hook (at line 5) change on every render. Move it inside the useEffect callback. Alternatively, wrap the initialization of \'foo\' in its own useMemo() Hook.', + 'suggestions': null, + }, + ], + }, + { + 'code': r''' + final Foo = uiFunction((_) { + final foo = {}; + print(foo); + useMemo(() { + print(foo); + }, [foo]); + }, null); + ''', + 'errors': [ + { + 'message': + 'The \'foo\' Map makes the dependencies of useMemo Hook (at line 6) change on every render. To fix this, wrap the initialization of \'foo\' in its own useMemo() Hook.', + 'suggestions': null, + }, + ], + }, + { + 'code': r''' + final Foo = uiFunction((_) { + final foo = Fragment()('Hi!'); + useMemo(() { + print(foo); + }, [foo]); + }, null); + ''', + 'errors': [ + { + 'message': + 'The \'foo\' ReactElement makes the dependencies of useMemo Hook (at line 5) change on every render. Move it inside the useMemo callback. Alternatively, wrap the initialization of \'foo\' in its own useMemo() Hook.', + 'suggestions': null, + }, + ], + }, + { + 'code': r''' + final Foo = uiFunction((_) { + final foo = Dom.div()('Hi!'); + useMemo(() { + print(foo); + }, [foo]); + }, null); + ''', + 'errors': [ + { + 'message': + 'The \'foo\' ReactElement makes the dependencies of useMemo Hook (at line 5) change on every render. Move it inside the useMemo callback. Alternatively, wrap the initialization of \'foo\' in its own useMemo() Hook.', + 'suggestions': null, + }, + ], + }, + { + 'code': r''' + final Foo = uiFunction((_) { + var bar; + final foo = bar = {}; + useMemo(() { + print(foo); + }, [foo]); + }, null); + ''', + 'errors': [ + { + 'message': + 'The \'foo\' assignment expression makes the dependencies of useMemo Hook (at line 6) change on every render. Move it inside the useMemo callback. Alternatively, wrap the initialization of \'foo\' in its own useMemo() Hook.', + 'suggestions': null, + }, + ], + }, + /* (1 cases previously here involving boxed primitives was removed, since there is no equivalent in Dart) */ + { + 'code': r''' + final Foo = uiFunction((_) { + final foo = Object(); + useMemo(() { + print(foo); + }, [foo]); + }, null); + ''', + 'errors': [ + { + 'message': + 'The \'foo\' Object makes the dependencies of useMemo Hook (at line 5) change on every render. Move it inside the useMemo callback. Alternatively, wrap the initialization of \'foo\' in its own useMemo() Hook.', + 'suggestions': null, + }, + ], + }, + { + 'code': r''' + final Foo = uiFunction((_) { + final foo = new Object(); + useMemo(() { + print(foo); + }, [foo]); + }, null); + ''', + 'errors': [ + { + 'message': + 'The \'foo\' Object makes the dependencies of useMemo Hook (at line 5) change on every render. Move it inside the useMemo callback. Alternatively, wrap the initialization of \'foo\' in its own useMemo() Hook.', + 'suggestions': null, + }, + ], + }, + /* (1 cases previously here involving regular expression literals was removed, since there is no equivalent in Dart) */ + /* (1 cases previously here involving class expressions was removed, since there is no equivalent in Dart) */ + { + 'code': r''' + final Foo = uiFunction((_) { + final foo = {}; + useLayoutEffect(() { + print(foo); + }, [foo]); + useEffect(() { + print(foo); + }, [foo]); + }, null); + ''', + 'errors': [ + { + 'message': + 'The \'foo\' Map makes the dependencies of useLayoutEffect Hook (at line 5) change on every render. To fix this, wrap the initialization of \'foo\' in its own useMemo() Hook.', + 'suggestions': null, + }, + { + 'message': + 'The \'foo\' Map makes the dependencies of useEffect Hook (at line 8) change on every render. To fix this, wrap the initialization of \'foo\' in its own useMemo() Hook.', + 'suggestions': null, + }, + ], + }, + ], +}; + +// Tests that are only valid/invalid across parsers supporting Flow +final Map>> testsFlow = { + 'valid': [ + /* 1 test case previously here was remove since generic function types aren't valid as arguments to function calls */ + ], + 'invalid': [ + { + 'code': r''' + final Foo = uiFunction((_) { + final dynamic foo = {}; + useMemo(() { + print(foo); + }, [foo]); + }, null); + ''', + 'errors': [ + { + 'message': + 'The \'foo\' Map makes the dependencies of useMemo Hook (at line 5) change on every render. Move it inside the useMemo callback. Alternatively, wrap the initialization of \'foo\' in its own useMemo() Hook.', + 'suggestions': null, + }, + ], + }, + ], +}; + +// Tests that are only valid/invalid across parsers supporting TypeScript +final Map>> testsTypescript = { + 'valid': [ + { + // '''ref''' is still constant, despite the cast. + 'code': r''' + final MyComponent = uiFunction((_) { + final ref = useRef() as over_react.Ref; + useEffect(() { + print(ref.current); + }, []); + }, null); + ''', + }, + /* (2 cases previously here involving typeof references was removed, since there is no equivalent in Dart) */ + ], + 'invalid': [ + { + // '''local''' is still non-constant, despite the cast. + 'code': r''' + final MyComponent = uiFunction((_) { + final local = ({} as dynamic) as String; + useEffect(() { + print(local); + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'local\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [local]', + 'output': r''' + final MyComponent = uiFunction((_) { + final local = ({} as dynamic) as String; + useEffect(() { + print(local); + }, [local]); + }, null); + ''', + }, + ], + }, + ], + }, + /* (1 case previously here involving typeof references was removed, since there is no equivalent in Dart) */ + { + 'code': r''' + final MyComponent = uiFunction((_) { + dynamic pizza; + useEffect(() => ({ + 'crust': pizza.crust, + 'toppings': pizza?.toppings, + }), []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has missing dependencies: \'pizza.crust\' and \'pizza?.toppings\'. Either include them or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [pizza.crust, pizza?.toppings]', + 'output': r''' + final MyComponent = uiFunction((_) { + dynamic pizza; + useEffect(() => ({ + 'crust': pizza.crust, + 'toppings': pizza?.toppings, + }), [pizza.crust, pizza?.toppings]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((_) { + dynamic pizza; + useEffect(() => ({ + 'crust': pizza?.crust, + 'density': pizza.crust.density, + }), []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'pizza.crust\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [pizza.crust]', + 'output': r''' + final MyComponent = uiFunction((_) { + dynamic pizza; + useEffect(() => ({ + 'crust': pizza?.crust, + 'density': pizza.crust.density, + }), [pizza.crust]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((_) { + dynamic pizza; + useEffect(() => ({ + 'crust': pizza.crust, + 'density': pizza?.crust.density, + }), []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'pizza.crust\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [pizza.crust]', + 'output': r''' + final MyComponent = uiFunction((_) { + dynamic pizza; + useEffect(() => ({ + 'crust': pizza.crust, + 'density': pizza?.crust.density, + }), [pizza.crust]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((_) { + dynamic pizza; + useEffect(() => ({ + 'crust': pizza?.crust, + 'density': pizza?.crust.density, + }), []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'pizza?.crust\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [pizza?.crust]', + 'output': r''' + final MyComponent = uiFunction((_) { + dynamic pizza; + useEffect(() => ({ + 'crust': pizza?.crust, + 'density': pizza?.crust.density, + }), [pizza?.crust]); + }, null); + ''', + }, + ], + }, + ], + }, + // Regression test. + { + 'code': r''' + final Example = uiFunction((props) { + useEffect(() { + var topHeight = 0; + topHeight = props.upperViewHeight; + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'props.upperViewHeight\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [props.upperViewHeight]', + 'output': r''' + final Example = uiFunction((props) { + useEffect(() { + var topHeight = 0; + topHeight = props.upperViewHeight; + }, [props.upperViewHeight]); + }, null); + ''', + }, + ], + }, + ], + }, + // Regression test. + { + 'code': r''' + final Example = uiFunction((props) { + useEffect(() { + var topHeight = 0; + topHeight = props?.upperViewHeight; + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'props?.upperViewHeight\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [props?.upperViewHeight]', + 'output': r''' + final Example = uiFunction((props) { + useEffect(() { + var topHeight = 0; + topHeight = props?.upperViewHeight; + }, [props?.upperViewHeight]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'code': r''' + final MyComponent = uiFunction((_) { + var state = over_react.useState(0); + useEffect(() { + final someNumber = 2; + state.setWithUpdater((prevState) => prevState + someNumber + state.value); + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'state.value\'. Either include it or remove the dependency list. You can also do a functional update \'state.setWithUpdater((s) => ...)\' if you only need \'state.value\' in the \'state.setWithUpdater\' call.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [state.value]', + 'output': r''' + final MyComponent = uiFunction((_) { + var state = over_react.useState(0); + useEffect(() { + final someNumber = 2; + state.setWithUpdater((prevState) => prevState + someNumber + state.value); + }, [state.value]); + }, null); + ''', + }, + ], + }, + ], + }, + /* (1 case previously here involving typeof references was removed, since there is no equivalent in Dart) */ + { + 'code': r''' + final Foo = uiFunction((_) { + final foo = {} as dynamic; + useMemo(() { + print(foo); + }, [foo]); + }, null); + ''', + 'errors': [ + { + 'message': + 'The \'foo\' Map makes the dependencies of useMemo Hook (at line 5) change on every render. Move it inside the useMemo callback. Alternatively, wrap the initialization of \'foo\' in its own useMemo() Hook.', + 'suggestions': null, + }, + ], + }, + { + 'name': 'StateHook as dependency, callback uses `.value`', + 'code': r''' + final MyComponent = uiFunction((_) { + final count = useState(0); + useEffect(() { + print(count.value); + }, [count]); + }, null); + ''', + 'errors': [ + { + 'message': + 'The \'count\' StateHook (from useState) makes the dependencies of React Hook useEffect change every render, and should not itself be a dependency.' + ' Since \'count.value\' is being used, depend on it instead.', + 'suggestions': [ + { + 'desc': 'Change the dependency to: count.value', + 'output': r''' + final MyComponent = uiFunction((_) { + final count = useState(0); + useEffect(() { + print(count.value); + }, [count.value]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'name': 'StateHook as dependency, callback uses property on `value`', + 'code': r''' + final MyComponent = uiFunction((_) { + final count = useState(0); + useEffect(() { + print(count.value.isEven); + }, [count]); + }, null); + ''', + 'errors': [ + { + 'message': + 'The \'count\' StateHook (from useState) makes the dependencies of React Hook useEffect change every render, and should not itself be a dependency.' + ' Since \'count.value\' is being used, depend on it instead.', + 'suggestions': [ + { + 'desc': 'Change the dependency to: count.value', + 'output': r''' + final MyComponent = uiFunction((_) { + final count = useState(0); + useEffect(() { + print(count.value.isEven); + }, [count.value]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'name': 'StateHook as dependency, callback uses `value` and property on `value`', + 'code': r''' + final MyComponent = uiFunction((_) { + final count = useState(0); + useEffect(() { + print(count.value); + print(count.value.isEven); + }, [count]); + }, null); + ''', + 'errors': [ + { + 'message': + 'The \'count\' StateHook (from useState) makes the dependencies of React Hook useEffect change every render, and should not itself be a dependency.' + ' Since \'count.value\' is being used, depend on it instead.', + 'suggestions': [ + { + 'desc': 'Change the dependency to: count.value', + 'output': r''' + final MyComponent = uiFunction((_) { + final count = useState(0); + useEffect(() { + print(count.value); + print(count.value.isEven); + }, [count.value]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'name': 'StateHook as dependency, callback uses `set`', + 'code': r''' + final MyComponent = uiFunction((_) { + final count = useState(0); + useEffect(() { + count.set(1); + }, [count]); + }, null); + ''', + 'errors': [ + { + 'message': + 'The \'count\' StateHook (from useState) makes the dependencies of React Hook useEffect change every render, and should not itself be a dependency.' + ' Since \'count.set\' is stable across renders, no dependencies are required to use it, and this dependency can be safely removed.', + 'suggestions': [ + { + 'desc': 'Remove the dependency on \'count\'.', + 'output': r''' + final MyComponent = uiFunction((_) { + final count = useState(0); + useEffect(() { + count.set(1); + }, []); + }, null); + ''', + }, + ], + }, + ], + }, + { + // Make sure we don't treat this case as a valid use of the dependency + 'name': 'StateHook as dependency, callback uses cascaded `set` and discards the object', + 'code': r''' + final MyComponent = uiFunction((_) { + final count = useState(0); + useEffect(() { + count..set(1); + }, [count]); + }, null); + ''', + 'errors': [ + { + 'message': + 'The \'count\' StateHook (from useState) makes the dependencies of React Hook useEffect change every render, and should not itself be a dependency.', + 'suggestions': null, + }, + ], + }, + { + // Make sure we don't treat this case as a valid use of the dependency + 'name': 'StateHook as dependency, callback uses cascaded `set` and uses the whole object', + 'code': r''' + final MyComponent = uiFunction((_) { + final count = useState(0); + useEffect(() { + print(count..set(1)); + }, [count]); + }, null); + ''', + 'errors': [ + { + 'message': + 'The \'count\' StateHook (from useState) makes the dependencies of React Hook useEffect change every render, and should not itself be a dependency.', + 'suggestions': null, + }, + ], + }, + { + // This tests: + // 1. That other dependencies are left alone + // 2. Removal of the comma after the dependency + 'name': 'StateHook as dependency, callback uses stable setter, and there are other dependencies in the list', + 'code': r''' + final MyComponent = uiFunction((props) { + final count = useState(0); + useEffect(() { + count.set(1); + print([props.foo, props.bar]); + }, [props.foo, count, props.bar]); + }, null); + ''', + 'errors': [ + { + 'message': + 'The \'count\' StateHook (from useState) makes the dependencies of React Hook useEffect change every render, and should not itself be a dependency.' + ' Since \'count.set\' is stable across renders, no dependencies are required to use it, and this dependency can be safely removed.', + 'suggestions': [ + { + 'desc': 'Remove the dependency on \'count\'.', + 'output': r''' + final MyComponent = uiFunction((props) { + final count = useState(0); + useEffect(() { + count.set(1); + print([props.foo, props.bar]); + }, [props.foo, props.bar]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'name': 'StateHook as dependency, callback uses `setWithUpdater`', + 'code': r''' + final MyComponent = uiFunction((_) { + final count = useState(0); + useEffect(() { + count.setWithUpdater((c) => c + 1); + }, [count]); + }, null); + ''', + 'errors': [ + { + 'message': + 'The \'count\' StateHook (from useState) makes the dependencies of React Hook useEffect change every render, and should not itself be a dependency.' + ' Since \'count.setWithUpdater\' is stable across renders, no dependencies are required to use it, and this dependency can be safely removed.', + 'suggestions': [ + { + 'desc': 'Remove the dependency on \'count\'.', + 'output': r''' + final MyComponent = uiFunction((_) { + final count = useState(0); + useEffect(() { + count.setWithUpdater((c) => c + 1); + }, []); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'name': 'StateHook as dependency, callback uses `set` and `setWithUpdater`', + 'code': r''' + final MyComponent = uiFunction((_) { + final count = useState(0); + useEffect(() { + count.set(1); + count.setWithUpdater((c) => c + 1); + }, [count]); + }, null); + ''', + 'errors': [ + { + 'message': + 'The \'count\' StateHook (from useState) makes the dependencies of React Hook useEffect change every render, and should not itself be a dependency.' + ' Since \'count.set\' and \'count.setWithUpdater\' are stable across renders, no dependencies are required to use them, and this dependency can be safely removed.', + 'suggestions': [ + { + 'desc': 'Remove the dependency on \'count\'.', + 'output': r''' + final MyComponent = uiFunction((_) { + final count = useState(0); + useEffect(() { + count.set(1); + count.setWithUpdater((c) => c + 1); + }, []); + }, null); + ''', + }, + ], + } + ], + }, + { + 'name': 'StateHook as dependency, callback uses `set` and `value`', + 'code': r''' + final MyComponent = uiFunction((_) { + final count = useState(0); + useEffect(() { + print(count.value); + count.set(0); + }, [count]); + }, null); + ''', + 'errors': [ + { + 'message': + 'The \'count\' StateHook (from useState) makes the dependencies of React Hook useEffect change every render, and should not itself be a dependency.' + ' Since \'count.set\' is stable across renders, no dependencies are required to use it.' + ' Since \'count.value\' is being used, depend on it instead.', + 'suggestions': [ + { + 'desc': 'Change the dependency to: count.value', + 'output': r''' + final MyComponent = uiFunction((_) { + final count = useState(0); + useEffect(() { + print(count.value); + count.set(0); + }, [count.value]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'name': 'StateHook as dependency, callback uses `value` inside `set`', + 'code': r''' + final MyComponent = uiFunction((_) { + final count = useState(0); + useEffect(() { + count.set(count.value + 1); + }, [count]); + }, null); + ''', + 'errors': [ + { + 'message': + 'The \'count\' StateHook (from useState) makes the dependencies of React Hook useEffect change every render, and should not itself be a dependency.' + ' Since \'count.set\' is stable across renders, no dependencies are required to use it.' + ' Since \'count.value\' is being used, depend on it instead.', + 'suggestions': [ + { + 'desc': 'Change the dependency to: count.value', + 'output': r''' + final MyComponent = uiFunction((_) { + final count = useState(0); + useEffect(() { + count.set(count.value + 1); + }, [count.value]); + }, null); + ''', + }, + ], + }, + ], + }, + { + 'name': 'StateHook as dependency, is unused', + 'code': r''' + final MyComponent = uiFunction((_) { + final count = useState(0); + useEffect(() {}, [count]); + }, null); + ''', + 'errors': [ + { + 'message': + 'The \'count\' StateHook (from useState) makes the dependencies of React Hook useEffect change every render, and should not itself be a dependency.', + 'suggestions': [ + { + 'desc': 'Remove the dependency on \'count\'.', + 'output': r''' + final MyComponent = uiFunction((_) { + final count = useState(0); + useEffect(() {}, []); + }, null); + ''', + }, + ], + } + ], + }, + { + 'name': 'StateHook as dependency, callback uses whole object', + 'code': r''' + final MyComponent = uiFunction((_) { + final count = useState(0); + useEffect(() { + print(count); + }, [count]); + }, null); + ''', + 'errors': [ + { + 'message': + 'The \'count\' StateHook (from useState) makes the dependencies of React Hook useEffect change every render, and should not itself be a dependency.', + 'suggestions': null, + } + ], + }, + { + 'name': 'StateHook as dependency, callback uses whole object and `value`', + 'code': r''' + final MyComponent = uiFunction((_) { + final count = useState(0); + useEffect(() { + print(count); + print(count.value); + }, [count]); + }, null); + ''', + 'errors': [ + { + // TODO(greg) improve the message for this case? + 'message': + 'The \'count\' StateHook (from useState) makes the dependencies of React Hook useEffect change every render, and should not itself be a dependency.' + ' Since \'count.value\' is being used, depend on it instead.', + 'suggestions': null, + } + ], + }, + { + 'name': 'StateHook as dependency, callback uses whole object and stable setter', + 'code': r''' + final MyComponent = uiFunction((_) { + final count = useState(0); + useEffect(() { + print(count); + count.set(1); + }, [count]); + }, null); + ''', + 'errors': [ + { + 'message': + 'The \'count\' StateHook (from useState) makes the dependencies of React Hook useEffect change every render, and should not itself be a dependency.' + ' Since \'count.set\' is stable across renders, no dependencies are required to use it.', + 'suggestions': null, + } + ], + }, + { + 'name': 'StateHook as dependency, callback uses whole object and `value` and stable setter', + 'code': r''' + final MyComponent = uiFunction((_) { + final count = useState(0); + useEffect(() { + print(count); + print(count.value); + count.set(1); + }, [count]); + }, null); + ''', + 'errors': [ + { + 'message': + 'The \'count\' StateHook (from useState) makes the dependencies of React Hook useEffect change every render, and should not itself be a dependency.' + ' Since \'count.set\' is stable across renders, no dependencies are required to use it.' + ' Since \'count.value\' is being used, depend on it instead.', + 'suggestions': null, + } + ], + }, + { + // No special case for this one. Once they add the dependency, they'll get a better message. + 'name': 'StateHook not a dependency, callback uses whole object', + 'code': r''' + final MyComponent = uiFunction((_) { + final count = useState(0); + useEffect(() { + print(count); + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'count\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [count]', + 'output': r''' + final MyComponent = uiFunction((_) { + final count = useState(0); + useEffect(() { + print(count); + }, [count]); + }, null); + ''' + } + ], + } + ], + }, + { + // No special case for this one. Once they add the dependency, they'll get a better message. + 'name': 'StateHook not a dependency, callback uses whole object and `value`', + 'code': r''' + final MyComponent = uiFunction((_) { + final count = useState(0); + useEffect(() { + print(count); + print(count.value); + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'count\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [count]', + 'output': r''' + final MyComponent = uiFunction((_) { + final count = useState(0); + useEffect(() { + print(count); + print(count.value); + }, [count]); + }, null); + ''' + } + ], + } + ], + }, + { + 'name': 'StateHook not a dependency, callback uses whole object and stable setter', + 'code': r''' + final MyComponent = uiFunction((_) { + final count = useState(0); + useEffect(() { + print(count); + count.set(1); + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'count\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [count]', + 'output': r''' + final MyComponent = uiFunction((_) { + final count = useState(0); + useEffect(() { + print(count); + count.set(1); + }, [count]); + }, null); + ''' + } + ], + } + ], + }, + { + // No special case for this one. Once they add the dependency, they'll get a better message. + 'name': 'StateHook not a dependency, callback uses whole object and and `value` and stable setter', + 'code': r''' + final MyComponent = uiFunction((_) { + final count = useState(0); + useEffect(() { + print(count); + print(count.value); + count.set(1); + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'count\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [count]', + 'output': r''' + final MyComponent = uiFunction((_) { + final count = useState(0); + useEffect(() { + print(count); + print(count.value); + count.set(1); + }, [count]); + }, null); + ''' + } + ], + } + ], + }, + { + // This test case is redundant with another test above, but is added among this set of tests for completeness. + 'name': 'StateHook not a dependency, callback uses value and stable setter', + 'code': r''' + final MyComponent = uiFunction((_) { + final count = useState(0); + useEffect(() { + print(count.value); + count.set(1); + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'count.value\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [count.value]', + 'output': r''' + final MyComponent = uiFunction((_) { + final count = useState(0); + useEffect(() { + print(count.value); + count.set(1); + }, [count.value]); + }, null); + ''' + } + ], + } + ], + }, + { + 'name': 'StateHook not a dependency, callback uses cascaded stable setter and discards the object', + 'code': r''' + final MyComponent = uiFunction((_) { + final count = useState(0); + useEffect(() { + count..set(1); + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect most likely has issues in its dependencies list, but the exact problems and recommended fixes could not be be computed since the dependency \'count\' is the target of a cascade. Try refactoring to not cascade on that dependency in the callback to get more helpful instructions and potentially a suggested fix.', + 'suggestions': null, + } + ], + }, + { + 'name': 'Generic type parameter referenced inside callback from pure scope', + 'code': r''' + void useSomething() { + useEffect(() { + final items = []; + }, []); + } + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'T\'. Either include it or remove the dependency list.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [T]', + 'output': r''' + void useSomething() { + useEffect(() { + final items = []; + }, [T]); + } + ''' + } + ], + } + ] + }, + ], +}; + +// Tests that are only valid/invalid for '''@typescript-eslint/parser@4.x''' +final Map>> testsTypescriptEslintParserV4 = { + 'valid': [], + 'invalid': [ + // TODO(ported):Should also be invalid as part of the JS test suite i.e. be invalid with babel eslint parsers. + // It doesn't use any explicit types but any JS is still valid TS. + { + 'code': r''' + final Foo = uiFunction((props) { + var Component = props.Component; + + over_react.useEffect(() { + print(Component()()); + }, []); + }, null); + ''', + 'errors': [ + { + 'message': + 'React Hook useEffect has a missing dependency: \'Component\'. Either include it or remove the dependency list. If \'Component\' changes too often, find the parent component that defines it and wrap that definition in useCallback.', + 'suggestions': [ + { + 'desc': 'Update the dependencies list to be: [Component]', + 'output': r''' + final Foo = uiFunction((props) { + var Component = props.Component; + + over_react.useEffect(() { + print(Component()()); + }, [Component]); + }, null); + ''', + }, + ], + }, + ], + }, + ], +}; diff --git a/tools/analyzer_plugin/test/integration/test_bases/analysis_driver_test_base.dart b/tools/analyzer_plugin/test/integration/test_bases/analysis_driver_test_base.dart index 019504e73..2ac904583 100644 --- a/tools/analyzer_plugin/test/integration/test_bases/analysis_driver_test_base.dart +++ b/tools/analyzer_plugin/test/integration/test_bases/analysis_driver_test_base.dart @@ -98,6 +98,7 @@ abstract class AnalysisDriverTestBase { _testPath = resourceProvider.newFolder('/test').path; // Add a analysis_options.yaml file to the [resourceProvider] if the contents are specified. + // If this isn't set up here, `AnalysisContext.optionsFile` will be null even if the file is added later. final contents = analysisOptionsYamlContents; if (contents != null) { final absolutePath = p.join(testPath, 'analysis_options.yaml'); diff --git a/tools/analyzer_plugin/test/integration/test_bases/diagnostic_test_base.dart b/tools/analyzer_plugin/test/integration/test_bases/diagnostic_test_base.dart index 395521e1f..fbeec88d5 100644 --- a/tools/analyzer_plugin/test/integration/test_bases/diagnostic_test_base.dart +++ b/tools/analyzer_plugin/test/integration/test_bases/diagnostic_test_base.dart @@ -87,15 +87,16 @@ abstract class DiagnosticTestBase extends ServerPluginContributorTestBase { {bool exactSelectionMatch = true}) => expectSingleErrorAt(createSelection(source, selection), exactSelectionMatch: exactSelectionMatch); - Future expectSingleErrorAt(SourceSelection selection, {bool exactSelectionMatch = true}) async { + Future expectSingleErrorAt(SourceSelection selection, + {bool exactSelectionMatch = true, bool? hasFix}) async { final errorsAtSelection = await _getAllErrorsAtSelection(selection); final reason = 'Expected a single error that matches `errorUnderTest` (selection: ${selection.target}.'; // Only use the equals/list matcher if we have a length other than 1 // since its mismatch message is more verbose for the length==1 case. errorsAtSelection.length == 1 - ? expect(errorsAtSelection.single, isAnErrorUnderTest(), reason: reason) - : expect(errorsAtSelection, [isAnErrorUnderTest()], reason: reason); + ? expect(errorsAtSelection.single, isAnErrorUnderTest(hasFix: hasFix), reason: reason) + : expect(errorsAtSelection, [isAnErrorUnderTest(hasFix: hasFix)], reason: reason); final error = errorsAtSelection.single; if (exactSelectionMatch) { @@ -113,7 +114,7 @@ abstract class DiagnosticTestBase extends ServerPluginContributorTestBase { /// fails the test if anything other than a single error fix is produced. Future expectSingleErrorFix(SourceSelection selection) async { _throwIfNoFix(); - final allErrorFixes = await _getAllErrorFixesAtSelection(selection); + final allErrorFixes = await getAllErrorFixesAtSelection(selection); expect(allErrorFixes, hasLength(1), reason: 'Expected only a single error with fixes at selection (selection: ${selection.target})'); final errorFix = allErrorFixes.single; @@ -129,15 +130,15 @@ abstract class DiagnosticTestBase extends ServerPluginContributorTestBase { /// Fails the test if [selection] produces any errors. Future expectNoErrorFix(SourceSelection selection) async { - expect(await _getAllErrorFixesAtSelection(selection), isEmpty, + expect(await getAllErrorFixesAtSelection(selection), isEmpty, reason: 'Unexpected error at selection: ${selection.target}'); } /// Returns all errors produced over the entire [source] and fails the test if /// any of them do not match [isAnErrorUnderTest] or [isAFixUnderTest]. Future getAllErrors(Source source, - {bool includeOtherCodes = false, DartErrorFilter dartErrorFilter = defaultDartErrorFilter}) async { - final errors = await _getAllErrors(source, dartErrorFilter: dartErrorFilter); + {bool includeOtherCodes = false, ErrorFilter errorFilter = defaultErrorFilter}) async { + final errors = await _getAllErrors(source, errorFilter: errorFilter); if (!includeOtherCodes) { expect(errors.dartErrors, isEmpty, reason: 'Expected there to be no errors coming from the analyzer and not the plugin.' @@ -149,6 +150,16 @@ abstract class DiagnosticTestBase extends ServerPluginContributorTestBase { return errors; } + /// Fails the test if there are any errors in [source]. + Future expectNoErrors(Source source, {ErrorFilter errorFilter = defaultErrorFilter}) async { + final errors = await _getAllErrors(source, errorFilter: errorFilter); + expect(errors.dartErrors, isEmpty, + reason: 'Expected there to be no errors coming from the analyzer and not the plugin.' + ' Ensure your test source is free of unintentional errors, such as syntax errors and missing imports.' + ' If errors are expected, set includeOtherErrorCodes:true.'); + expect(errors.pluginErrors, isEmpty, reason: 'Expected there to be no plugin errors.'); + } + Future getAllErrorsAtSelection(SourceSelection selection, {bool includeOtherCodes = false}) async { final errors = await _getAllErrorsAtSelection(selection); @@ -176,7 +187,7 @@ abstract class DiagnosticTestBase extends ServerPluginContributorTestBase { } Future _getAllErrors(Source source, - {DartErrorFilter dartErrorFilter = defaultDartErrorFilter}) async { + {ErrorFilter errorFilter = defaultErrorFilter}) async { final result = await testPlugin.getResolvedUnitResult(sourcePath(source)); final dartErrors = AnalyzerConverter() .convertAnalysisErrors( @@ -184,14 +195,15 @@ abstract class DiagnosticTestBase extends ServerPluginContributorTestBase { lineInfo: result.lineInfo, options: result.session.analysisContext.analysisOptions, ) - .where(dartErrorFilter) + .where((e) => errorFilter(e, isFromPlugin: false)) .toList(); - final pluginErrors = await testPlugin.getAllErrors(result); + final pluginErrors = + (await testPlugin.getAllErrors(result)).where((e) => errorFilter(e, isFromPlugin: true)).toList(); return DartAndPluginErrorsIterable(dartErrors: dartErrors, pluginErrors: pluginErrors); } /// Returns all error fixes produced at [selection]. - Future> _getAllErrorFixesAtSelection(SourceSelection selection) async { + Future> getAllErrorFixesAtSelection(SourceSelection selection) async { final parameters = EditGetFixesParams(sourcePath(selection.source), selection.offset); return (await testPlugin.handleEditGetFixes(parameters)).fixes; } @@ -210,7 +222,12 @@ class DartAndPluginErrorsIterable extends CombinedIterableView { : super([dartErrors, pluginErrors]); } -typedef DartErrorFilter = bool Function(AnalysisError); +typedef ErrorFilter = bool Function(AnalysisError, {required bool isFromPlugin}); -bool defaultDartErrorFilter(AnalysisError error) => - error.severity != AnalysisErrorSeverity.INFO && error.code != 'uri_has_not_been_generated'; +bool defaultErrorFilter(AnalysisError error, {required bool isFromPlugin}) => + // Only filter out infos that don't come from the plugin. + (isFromPlugin || error.severity != AnalysisErrorSeverity.INFO) && + !(const { + 'over_react_debug_analyzer_plugin_helper', + 'uri_has_not_been_generated', + }.contains(error.code)); diff --git a/tools/analyzer_plugin/test/unit/util/ast_util_test.dart b/tools/analyzer_plugin/test/unit/util/ast_util_test.dart index 11b2ae8f4..16e7cb3a6 100644 --- a/tools/analyzer_plugin/test/unit/util/ast_util_test.dart +++ b/tools/analyzer_plugin/test/unit/util/ast_util_test.dart @@ -1,5 +1,6 @@ import 'package:analyzer/dart/ast/ast.dart'; import 'package:over_react_analyzer_plugin/src/util/ast_util.dart'; +import 'package:over_react_analyzer_plugin/src/util/util.dart'; import 'package:test/test.dart'; import '../../test_util.dart'; @@ -63,5 +64,229 @@ void main() { expect(body.returnExpressions, isEmpty); }); }); + + group('lookup functions', () { + Iterable getAllPrintedExpressions(AstNode root) => allDescendantsOfType(root) + .where((e) => e.function.tryCast()?.name == 'print') + .map((printCall) => printCall.argumentList.arguments[0]); + + group('lookUpVariable', () { + test('looks up a variable', () async { + final unit = (await parseAndGetResolvedUnit(/*language=dart*/ r''' + someFunction() { + var foo = 0; + print(foo); + } + ''')).unit!; + final usage = getAllPrintedExpressions(unit).single as Identifier; + expect(usage.name, 'foo', reason: 'test setup check'); + expect(usage.staticElement, isNotNull, reason: 'test setup check'); + expect(lookUpVariable(usage.staticElement!, unit)?.name.name, 'foo'); + }); + + group('returns null when', () { + test('the element does not correspond to a variable', () async { + final unit = (await parseAndGetResolvedUnit(/*language=dart*/ r''' + someFunction() { + foo() {} + print(foo); + } + ''')).unit!; + final usage = getAllPrintedExpressions(unit).single as Identifier; + expect(usage.name, 'foo', reason: 'test setup check'); + expect(usage.staticElement, isNotNull, reason: 'test setup check'); + expect(lookUpVariable(usage.staticElement!, unit), isNull); + }); + + test('the element does not exist within a given node', () async { + final unit = (await parseAndGetResolvedUnit(/*language=dart*/ r''' + someFunction() { + var foo = 0; + print(foo); + } + ''')).unit!; + final usage = getAllPrintedExpressions(unit).single as Identifier; + expect(usage.name, 'foo', reason: 'test setup check'); + expect(usage.staticElement, isNotNull, reason: 'test setup check'); + expect(lookUpVariable(usage.staticElement!, usage), isNull); + }); + }); + }); + + group('lookUpFunction', () { + test('looks up a function declaration', () async { + final unit = (await parseAndGetResolvedUnit(/*language=dart*/ r''' + someFunction() { + foo() => 'I am the body'; + print(foo); + } + ''')).unit!; + final usage = getAllPrintedExpressions(unit).single as Identifier; + expect(usage.name, 'foo', reason: 'test setup check'); + expect(usage.staticElement, isNotNull, reason: 'test setup check'); + expect(lookUpFunction(usage.staticElement!, unit)?.body.toSource(), contains('I am the body')); + }); + + test('looks up a variable initialize to a function', () async { + final unit = (await parseAndGetResolvedUnit(/*language=dart*/ r''' + someFunction() { + var foo = () => 'I am the body'; + print(foo); + } + ''')).unit!; + final usage = getAllPrintedExpressions(unit).single as Identifier; + expect(usage.name, 'foo', reason: 'test setup check'); + expect(usage.staticElement, isNotNull, reason: 'test setup check'); + expect(lookUpFunction(usage.staticElement!, unit)?.body.toSource(), contains('I am the body')); + }); + + group('returns null when', () { + test('the element does not correspond to a function', () async { + final unit = (await parseAndGetResolvedUnit(/*language=dart*/ r''' + someFunction() { + var foo = 1; + print(foo); + } + ''')).unit!; + final usage = getAllPrintedExpressions(unit).single as Identifier; + expect(usage.name, 'foo', reason: 'test setup check'); + expect(usage.staticElement, isNotNull, reason: 'test setup check'); + expect(lookUpFunction(usage.staticElement!, unit), isNull); + }); + + test('the element does not exist within a given node', () async { + final unit = (await parseAndGetResolvedUnit(/*language=dart*/ r''' + someFunction() { + foo() => 'I am the body'; + print(foo); + } + ''')).unit!; + final usage = getAllPrintedExpressions(unit).single as Identifier; + expect(usage.name, 'foo', reason: 'test setup check'); + expect(usage.staticElement, isNotNull, reason: 'test setup check'); + expect(lookUpFunction(usage.staticElement!, usage), isNull); + }); + }); + }); + + group('lookUpDeclaration', () { + test('looks up a function', () async { + final unit = (await parseAndGetResolvedUnit(/*language=dart*/ r''' + someFunction() { + foo() {} + print(foo); + } + ''')).unit!; + final usage = getAllPrintedExpressions(unit).single as Identifier; + expect(usage.name, 'foo', reason: 'test setup check'); + expect(usage.staticElement, isNotNull, reason: 'test setup check'); + expect((lookUpDeclaration(usage.staticElement!, unit) as FunctionDeclaration).name.name, 'foo'); + }); + + test('looks up a variable', () async { + final unit = (await parseAndGetResolvedUnit(/*language=dart*/ r''' + someFunction() { + var foo = 1; + print(foo); + } + ''')).unit!; + final usage = getAllPrintedExpressions(unit).single as Identifier; + expect(usage.name, 'foo', reason: 'test setup check'); + expect(usage.staticElement, isNotNull, reason: 'test setup check'); + expect((lookUpDeclaration(usage.staticElement!, unit) as VariableDeclaration).name.name, 'foo'); + }); + + test('looks up a class', () async { + final unit = (await parseAndGetResolvedUnit(/*language=dart*/ r''' + class Foo {} + + someFunction() { + print(Foo); + } + ''')).unit!; + final usage = getAllPrintedExpressions(unit).single as Identifier; + expect(usage.name, 'Foo', reason: 'test setup check'); + expect(usage.staticElement, isNotNull, reason: 'test setup check'); + expect((lookUpDeclaration(usage.staticElement!, unit) as ClassDeclaration).name.name, 'Foo'); + }); + + group('returns null when', () { + test('the element does not correspond to a declaration', () async { + final unit = (await parseAndGetResolvedUnit(/*language=dart*/ r''' + import 'dart:html'; + ''')).unit!; + final usage = allDescendantsOfType(unit).single; + expect(usage.element, isNotNull, reason: 'test setup check'); + expect(lookUpFunction(usage.element!, unit), isNull); + }); + + test('the element does not exist within a given node', () async { + final unit = (await parseAndGetResolvedUnit(/*language=dart*/ r''' + someFunction() { + foo() {}; + print(foo); + } + ''')).unit!; + final usage = getAllPrintedExpressions(unit).single as Identifier; + expect(usage.name, 'foo', reason: 'test setup check'); + expect(usage.staticElement, isNotNull, reason: 'test setup check'); + expect(lookUpFunction(usage.staticElement!, usage), isNull); + }); + }); + }); + + group('lookUpParameter', () { + test('looks up a required parameter', () async { + final unit = (await parseAndGetResolvedUnit(/*language=dart*/ r''' + someFunction(int foo) { + print(foo); + } + ''')).unit!; + final usage = getAllPrintedExpressions(unit).single as Identifier; + expect(usage.name, 'foo', reason: 'test setup check'); + expect(usage.staticElement, isNotNull, reason: 'test setup check'); + expect(lookUpParameter(usage.staticElement!, unit)?.identifier?.name, 'foo'); + }); + + test('looks up a named parameter', () async { + final unit = (await parseAndGetResolvedUnit(/*language=dart*/ r''' + someFunction({int foo}) { + print(foo); + } + ''')).unit!; + final usage = getAllPrintedExpressions(unit).single as Identifier; + expect(usage.name, 'foo', reason: 'test setup check'); + expect(usage.staticElement, isNotNull, reason: 'test setup check'); + expect(lookUpParameter(usage.staticElement!, unit)?.identifier?.name, 'foo'); + }); + + group('returns null when', () { + test('the element does not correspond to a parameter', () async { + final unit = (await parseAndGetResolvedUnit(/*language=dart*/ r''' + someFunction() { + foo() {} + print(foo); + } + ''')).unit!; + final usage = getAllPrintedExpressions(unit).single as Identifier; + expect(usage.name, 'foo', reason: 'test setup check'); + expect(usage.staticElement, isNotNull, reason: 'test setup check'); + expect(lookUpParameter(usage.staticElement!, unit), isNull); + }); + + test('the element does not exist within a given node', () async { + final unit = (await parseAndGetResolvedUnit(/*language=dart*/ r''' + someFunction(int foo) { + print(foo); + } + ''')).unit!; + final usage = getAllPrintedExpressions(unit).single as Identifier; + expect(usage.name, 'foo', reason: 'test setup check'); + expect(usage.staticElement, isNotNull, reason: 'test setup check'); + expect(lookUpParameter(usage.staticElement!, usage), isNull); + }); + }); + }); + }); }); }