Repository: facebook/react
Pull request: [forgive] Add code action to remove dependency array


Adds a new codeaction event in the compiler and handler in forgive. This allows you to remove a dependency array when you're editing a range that is within an autodep eligible function.

Co-authored-by: Jordan Brown <jmbrown@meta.com>
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/facebook/react/pull/33000).
* #33002
* #33001
* __->__ #33000

Changed files:
filename: compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Options.ts
patch: @@ -183,7 +183,8 @@ export type LoggerEvent =
   | CompileSkipEvent
   | PipelineErrorEvent
   | TimingEvent
-  | AutoDepsDecorationsEvent;
+  | AutoDepsDecorationsEvent
+  | AutoDepsEligibleEvent;
 
 export type CompileErrorEvent = {
   kind: 'CompileError';
@@ -222,9 +223,14 @@ export type TimingEvent = {
 };
 export type AutoDepsDecorationsEvent = {
   kind: 'AutoDepsDecorations';
-  useEffectCallExpr: t.SourceLocation;
+  fnLoc: t.SourceLocation;
   decorations: Array<t.SourceLocation>;
 };
+export type AutoDepsEligibleEvent = {
+  kind: 'AutoDepsEligible';
+  fnLoc: t.SourceLocation;
+  depArrayLoc: t.SourceLocation;
+};
 
 export type Logger = {
   logEvent: (filename: string | null, event: LoggerEvent) => void;

filename: compiler/packages/babel-plugin-react-compiler/src/Inference/InferEffectDependencies.ts
patch: @@ -230,7 +230,7 @@ export function inferEffectDependencies(fn: HIRFunction): void {
             if (typeof value.loc !== 'symbol') {
               fn.env.logger?.logEvent(fn.env.filename, {
                 kind: 'AutoDepsDecorations',
-                useEffectCallExpr: value.loc,
+                fnLoc: value.loc,
                 decorations,
               });
             }
@@ -258,6 +258,30 @@ export function inferEffectDependencies(fn: HIRFunction): void {
             rewriteInstrs.set(instr.id, newInstructions);
             fn.env.inferredEffectLocations.add(callee.loc);
           }
+        } else if (
+          value.args.length >= 2 &&
+          value.args.length - 1 === autodepFnLoads.get(callee.identifier.id) &&
+          value.args[0].kind === 'Identifier'
+        ) {
+          const penultimateArg = value.args[value.args.length - 2];
+          const depArrayArg = value.args[value.args.length - 1];
+          if (
+            depArrayArg.kind !== 'Spread' &&
+            penultimateArg.kind !== 'Spread' &&
+            typeof depArrayArg.loc !== 'symbol' &&
+            typeof penultimateArg.loc !== 'symbol' &&
+            typeof value.loc !== 'symbol'
+          ) {
+            fn.env.logger?.logEvent(fn.env.filename, {
+              kind: 'AutoDepsEligible',
+              fnLoc: value.loc,
+              depArrayLoc: {
+                ...depArrayArg.loc,
+                start: penultimateArg.loc.end,
+                end: depArrayArg.loc.end,
+              },
+            });
+          }
         }
       }
     }

filename: compiler/packages/react-forgive/server/src/index.ts
patch: @@ -7,10 +7,13 @@
 
 import {TextDocument} from 'vscode-languageserver-textdocument';
 import {
+  CodeAction,
+  CodeActionKind,
   CodeLens,
   createConnection,
   type InitializeParams,
   type InitializeResult,
+  Position,
   ProposedFeatures,
   TextDocuments,
   TextDocumentSyncKind,
@@ -29,7 +32,12 @@ import {
   AutoDepsDecorationsRequest,
   mapCompilerEventToLSPEvent,
 } from './requests/autodepsdecorations';
-import {isPositionWithinRange} from './utils/range';
+import {
+  isPositionWithinRange,
+  isRangeWithinRange,
+  Range,
+  sourceLocationToRange,
+} from './utils/range';
 
 const SUPPORTED_LANGUAGE_IDS = new Set([
   'javascript',
@@ -44,6 +52,15 @@ const documents = new TextDocuments(TextDocument);
 let compilerOptions: PluginOptions | null = null;
 let compiledFns: Set<CompileSuccessEvent> = new Set();
 let autoDepsDecorations: Array<AutoDepsDecorationsLSPEvent> = [];
+let codeActionEvents: Array<CodeActionLSPEvent> = [];
+
+type CodeActionLSPEvent = {
+  title: string;
+  kind: CodeActionKind;
+  newText: string;
+  anchorRange: Range;
+  editRange: {start: Position; end: Position};
+};
 
 connection.onInitialize((_params: InitializeParams) => {
   // TODO(@poteto) get config fr
@@ -85,13 +102,24 @@ connection.onInitialize((_params: InitializeParams) => {
         if (event.kind === 'AutoDepsDecorations') {
           autoDepsDecorations.push(mapCompilerEventToLSPEvent(event));
         }
+        if (event.kind === 'AutoDepsEligible') {
+          const depArrayLoc = sourceLocationToRange(event.depArrayLoc);
+          codeActionEvents.push({
+            title: 'Use React Compiler inferred dependency array',
+            kind: CodeActionKind.QuickFix,
+            newText: '',
+            anchorRange: sourceLocationToRange(event.fnLoc),
+            editRange: {start: depArrayLoc[0], end: depArrayLoc[1]},
+          });
+        }
       },
     },
   };
   const result: InitializeResult = {
     capabilities: {
       textDocumentSync: TextDocumentSyncKind.Full,
       codeLensProvider: {resolveProvider: true},
+      codeActionProvider: {resolveProvider: true},
     },
   };
   return result;
@@ -103,8 +131,7 @@ connection.onInitialized(() => {
 
 documents.onDidChangeContent(async event => {
   connection.console.info(`Changed: ${event.document.uri}`);
-  compiledFns.clear();
-  autoDepsDecorations = [];
+  resetState();
   if (SUPPORTED_LANGUAGE_IDS.has(event.document.languageId)) {
     const text = event.document.getText();
     await compile({
@@ -116,8 +143,7 @@ documents.onDidChangeContent(async event => {
 });
 
 connection.onDidChangeWatchedFiles(change => {
-  compiledFns.clear();
-  autoDepsDecorations = [];
+  resetState();
   connection.console.log(
     change.changes.map(c => `File changed: ${c.uri}`).join('\n'),
   );
@@ -157,6 +183,44 @@ connection.onCodeLensResolve(lens => {
   return lens;
 });
 
+connection.onCodeAction(params => {
+  connection.console.log('onCodeAction');
+  connection.console.log(JSON.stringify(params, null, 2));
+  const codeActions: Array<CodeAction> = [];
+  for (const codeActionEvent of codeActionEvents) {
+    if (
+      isRangeWithinRange(
+        [params.range.start, params.range.end],
+        codeActionEvent.anchorRange,
+      )
+    ) {
+      codeActions.push(
+        CodeAction.create(
+          codeActionEvent.title,
+          {
+            changes: {
+              [params.textDocument.uri]: [
+                {
+                  newText: codeActionEvent.newText,
+                  range: codeActionEvent.editRange,
+                },
+              ],
+            },
+          },
+          codeActionEvent.kind,
+        ),
+      );
+    }
+  }
+  return codeActions;
+});
+
+connection.onCodeActionResolve(codeAction => {
+  connection.console.log('onCodeActionResolve');
+  connection.console.log(JSON.stringify(codeAction, null, 2));
+  return codeAction;
+});
+
 connection.onRequest(AutoDepsDecorationsRequest.type, async params => {
   const position = params.position;
   connection.console.debug('Client hovering on: ' + JSON.stringify(position));
@@ -168,6 +232,12 @@ connection.onRequest(AutoDepsDecorationsRequest.type, async params => {
   return null;
 });
 
+function resetState() {
+  compiledFns.clear();
+  autoDepsDecorations = [];
+  codeActionEvents = [];
+}
+
 documents.listen(connection);
 connection.listen();
 connection.console.info(`React Analyzer running in node ${process.version}`);

filename: compiler/packages/react-forgive/server/src/requests/autodepsdecorations.ts
patch: @@ -22,7 +22,7 @@ export function mapCompilerEventToLSPEvent(
   event: AutoDepsDecorationsEvent,
 ): AutoDepsDecorationsLSPEvent {
   return {
-    useEffectCallExpr: sourceLocationToRange(event.useEffectCallExpr),
+    useEffectCallExpr: sourceLocationToRange(event.fnLoc),
     decorations: event.decorations.map(sourceLocationToRange),
   };
 }

filename: compiler/packages/react-forgive/server/src/utils/range.ts
patch: @@ -2,13 +2,29 @@ import * as t from '@babel/types';
 import {type Position} from 'vscode-languageserver/node';
 
 export type Range = [Position, Position];
+
 export function isPositionWithinRange(
   position: Position,
   [start, end]: Range,
 ): boolean {
   return position.line >= start.line && position.line <= end.line;
 }
 
+export function isRangeWithinRange(aRange: Range, bRange: Range): boolean {
+  const startComparison = comparePositions(aRange[0], bRange[0]);
+  const endComparison = comparePositions(aRange[1], bRange[1]);
+  return startComparison >= 0 && endComparison <= 0;
+}
+
+function comparePositions(a: Position, b: Position): number {
+  const lineComparison = a.line - b.line;
+  if (lineComparison === 0) {
+    return a.character - b.character;
+  } else {
+    return lineComparison;
+  }
+}
+
 export function sourceLocationToRange(
   loc: t.SourceLocation,
 ): [Position, Position] {