diff --git a/lib/src/dart3_suggestors/null_safety_prep/dom_callback_null_args.dart b/lib/src/dart3_suggestors/null_safety_prep/dom_callback_null_args.dart new file mode 100644 index 00000000..dd54fc8d --- /dev/null +++ b/lib/src/dart3_suggestors/null_safety_prep/dom_callback_null_args.dart @@ -0,0 +1,128 @@ +// Copyright 2024 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/analysis/results.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; +import 'package:analyzer/dart/element/type.dart'; +import 'package:collection/collection.dart'; +import 'package:over_react_codemod/src/util/class_suggestor.dart'; + +/// Suggestor that replaces a `null` literal argument passed to a "DOM" callback +/// with a generated `SyntheticEvent` object of the expected type. +/// +/// Example: +/// +/// ```dart +/// final props = domProps(); +/// // Before +/// props.onClick(null); +/// // After +/// props.onClick(createSyntheticMouseEvent()); +/// ``` +class DomCallbackNullArgs extends RecursiveAstVisitor with ClassSuggestor { + ResolvedUnitResult? _result; + + @override + visitArgumentList(ArgumentList node) { + super.visitArgumentList(node); + + if (node.arguments.isEmpty) return; + dynamic firstArg = node.arguments.elementAt(0); + if (firstArg is! NullLiteral) return; + + dynamic possibleCallback = node.parent; + if (possibleCallback is FunctionExpressionInvocation) { + String fnName = ''; + if (possibleCallback.function is PropertyAccess) { + fnName = + (possibleCallback.function as PropertyAccess).propertyName.name; + } else if (possibleCallback.function is SimpleIdentifier) { + fnName = (possibleCallback.function as SimpleIdentifier).name; + } + + if (callbackToSyntheticEventTypeMap.keys.contains(fnName)) { + dynamic possibleSyntheticEventCallbackFn = + possibleCallback.staticInvokeType; + if (possibleSyntheticEventCallbackFn is FunctionType) { + final syntheticEventTypeName = possibleSyntheticEventCallbackFn + .parameters.firstOrNull?.type.element?.name; + yieldPatch('create${syntheticEventTypeName}()', + firstArg.literal.offset, firstArg.literal.end); + } + } + } + } + + @override + Future generatePatches() async { + _result = await context.getResolvedUnit(); + if (_result == null) { + throw Exception( + 'Could not get resolved result for "${context.relativePath}"'); + } + _result!.unit.accept(this); + } + + static const callbackToSyntheticEventTypeMap = { + 'onAnimationEnd': 'SyntheticAnimationEvent', + 'onAnimationIteration': 'SyntheticAnimationEvent', + 'onAnimationStart': 'SyntheticAnimationEvent', + 'onCopy': 'SyntheticClipboardEvent', + 'onCut': 'SyntheticClipboardEvent', + 'onPaste': 'SyntheticClipboardEvent', + 'onKeyDown': 'SyntheticKeyboardEvent', + 'onKeyPress': 'SyntheticKeyboardEvent', + 'onKeyUp': 'SyntheticKeyboardEvent', + 'onFocus': 'SyntheticFocusEvent', + 'onBlur': 'SyntheticFocusEvent', + 'onChange': 'SyntheticFormEvent', + 'onInput': 'SyntheticFormEvent', + 'onSubmit': 'SyntheticFormEvent', + 'onReset': 'SyntheticFormEvent', + 'onClick': 'SyntheticMouseEvent', + 'onContextMenu': 'SyntheticMouseEvent', + 'onDoubleClick': 'SyntheticMouseEvent', + 'onDrag': 'SyntheticMouseEvent', + 'onDragEnd': 'SyntheticMouseEvent', + 'onDragEnter': 'SyntheticMouseEvent', + 'onDragExit': 'SyntheticMouseEvent', + 'onDragLeave': 'SyntheticMouseEvent', + 'onDragOver': 'SyntheticMouseEvent', + 'onDragStart': 'SyntheticMouseEvent', + 'onDrop': 'SyntheticMouseEvent', + 'onMouseDown': 'SyntheticMouseEvent', + 'onMouseEnter': 'SyntheticMouseEvent', + 'onMouseLeave': 'SyntheticMouseEvent', + 'onMouseMove': 'SyntheticMouseEvent', + 'onMouseOut': 'SyntheticMouseEvent', + 'onMouseOver': 'SyntheticMouseEvent', + 'onMouseUp': 'SyntheticMouseEvent', + 'onPointerCancel': 'SyntheticPointerEvent', + 'onPointerDown': 'SyntheticPointerEvent', + 'onPointerEnter': 'SyntheticPointerEvent', + 'onPointerLeave': 'SyntheticPointerEvent', + 'onPointerMove': 'SyntheticPointerEvent', + 'onPointerOver': 'SyntheticPointerEvent', + 'onPointerOut': 'SyntheticPointerEvent', + 'onPointerUp': 'SyntheticPointerEvent', + 'onTouchCancel': 'SyntheticTouchEvent', + 'onTouchEnd': 'SyntheticTouchEvent', + 'onTouchMove': 'SyntheticTouchEvent', + 'onTouchStart': 'SyntheticTouchEvent', + 'onTransitionEnd': 'SyntheticTransitionEvent', + 'onScroll': 'SyntheticUIEvent', + 'onWheel': 'SyntheticWheelEvent', + }; +} diff --git a/lib/src/executables/null_safety_prep.dart b/lib/src/executables/null_safety_prep.dart index 6321665c..8879e6ff 100644 --- a/lib/src/executables/null_safety_prep.dart +++ b/lib/src/executables/null_safety_prep.dart @@ -16,8 +16,8 @@ import 'dart:io'; import 'package:args/args.dart'; import 'package:codemod/codemod.dart'; +import 'package:over_react_codemod/src/dart3_suggestors/null_safety_prep/dom_callback_null_args.dart'; import 'package:over_react_codemod/src/dart3_suggestors/null_safety_prep/use_ref_init_migration.dart'; -import 'package:over_react_codemod/src/ignoreable.dart'; import 'package:over_react_codemod/src/util.dart'; const _changesRequiredOutput = """ @@ -36,7 +36,8 @@ void main(List args) async { dartPaths, aggregate([ UseRefInitMigration(), - ].map((s) => ignoreable(s))), + DomCallbackNullArgs(), + ]), defaultYes: true, args: parsedArgs.rest, additionalHelpOutput: parser.usage, diff --git a/test/dart3_suggestors/null_safety_prep/dom_callback_null_args_test.dart b/test/dart3_suggestors/null_safety_prep/dom_callback_null_args_test.dart new file mode 100644 index 00000000..57498df8 --- /dev/null +++ b/test/dart3_suggestors/null_safety_prep/dom_callback_null_args_test.dart @@ -0,0 +1,98 @@ +// Copyright 2024 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:over_react_codemod/src/dart3_suggestors/null_safety_prep/dom_callback_null_args.dart'; +import 'package:test/test.dart'; + +import '../../resolved_file_context.dart'; +import '../../util.dart'; +import '../../util/component_usage_migrator_test.dart'; + +void main() { + final resolvedContext = SharedAnalysisContext.overReact; + + // Warm up analysis in a setUpAll so that if getting the resolved AST times out + // (which is more common for the WSD context), it fails here instead of failing the first test. + setUpAll(resolvedContext.warmUpAnalysis); + + group('DomCallbackNullArgs', () { + late SuggestorTester testSuggestor; + + setUp(() { + testSuggestor = getSuggestorTester( + DomCallbackNullArgs(), + resolvedContext: resolvedContext, + ); + }); + + test( + 'leaves dom callbacks alone when a non-null value is passed as the first argument', + () async { + await testSuggestor( + expectedPatchCount: 0, + input: withOverReactImport(''' + main() { + final props = domProps(); + props.onClick(createSyntheticMouseEvent()); + final onBlur = props.onBlur; + onBlur(createSyntheticFocusEvent()); + } + '''), + ); + }); + + test( + 'leaves functions alone when a null value is passed as the first argument if they are not dom callbacks', + () async { + await testSuggestor( + expectedPatchCount: 0, + input: withOverReactImport(''' + main() { + void foo(dynamic arg) {} + foo(null); + } + '''), + ); + }); + + group( + 'replaces null arg in dom callback with an empty synthetic event of the correct type: ', + () { + DomCallbackNullArgs.callbackToSyntheticEventTypeMap + .forEach((callbackFnName, syntheticEventTypeName) { + test(callbackFnName, () async { + await testSuggestor( + expectedPatchCount: 2, + input: withOverReactImport(''' + main() { + final props = domProps(); + props.${callbackFnName}(null); + final ${callbackFnName} = props.${callbackFnName}; + ${callbackFnName}(null); + } + '''), + expectedOutput: withOverReactImport(''' + main() { + final props = domProps(); + props.${callbackFnName}(create${syntheticEventTypeName}()); + final ${callbackFnName} = props.${callbackFnName}; + ${callbackFnName}(create${syntheticEventTypeName}()); + } + '''), + ); + }); + }); + }); + }); +}