Add eslint rule for binding scheduleRender render argument

Bug: NO_BUG
Change-Id: I64d643a65d0cf4bc3546ed4f75adde7a53c83c4e
Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/3624641
Reviewed-by: Jack Franklin <[email protected]>
Commit-Queue: Ergün Erdoğmuş <[email protected]>
diff --git a/front_end/.eslintrc.js b/front_end/.eslintrc.js
index d5136d2..deadff1 100644
--- a/front_end/.eslintrc.js
+++ b/front_end/.eslintrc.js
@@ -2,6 +2,7 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
+// clang-format off
 const path = require('path');
 const rulesDirPlugin = require('eslint-plugin-rulesdir');
 rulesDirPlugin.RULES_DIR = path.join(__dirname, '..', 'scripts', 'eslint_rules', 'lib');
@@ -28,6 +29,7 @@
       'rules': {
         '@typescript-eslint/explicit-function-return-type': 2,
         'rulesdir/no_importing_images_from_src': 2,
+        'rulesdir/enforce_bound_render_for_schedule_render': 2,
         'rulesdir/enforce_custom_event_names': 2,
         'rulesdir/set_data_type_reference': 2,
         'rulesdir/no_bound_component_methods': 2,
@@ -120,10 +122,7 @@
           },
           {
             // Ignore type properties that require quotes
-            'selector': [
-              'typeProperty',
-              'enumMember'
-            ],
+            'selector': ['typeProperty', 'enumMember'],
             'format': null,
             'modifiers': ['requiresQuotes']
           }
@@ -150,3 +149,4 @@
     }
   ]
 };
+// clang-format on
diff --git a/scripts/eslint_rules/lib/enforce_bound_render_for_schedule_render.js b/scripts/eslint_rules/lib/enforce_bound_render_for_schedule_render.js
new file mode 100644
index 0000000..be5c456
--- /dev/null
+++ b/scripts/eslint_rules/lib/enforce_bound_render_for_schedule_render.js
@@ -0,0 +1,110 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+'use strict';
+
+function goToClassDeclaration(node) {
+  if (!node) {
+    return null;
+  }
+
+  if (node.type === 'ClassDeclaration') {
+    return node;
+  }
+
+  return goToClassDeclaration(node.parent);
+}
+
+function isMemberExpressionOnThis(memberExpression) {
+  if (!memberExpression) {
+    return false;
+  }
+
+  if (memberExpression.object.type === 'ThisExpression') {
+    // Take into `a.this.bind()` case into account
+    // `this` must be the last object in the `MemberExpression` chain
+    return !memberExpression.object.object;
+  }
+
+  return isMemberExpressionOnThis(memberExpression.object);
+}
+
+// Whether the right hand side of property definition is `this.xxx.yyy.bind(this);`
+function isPropertyDefinitionViaBindCallToThis(propertyDefinition) {
+  if (propertyDefinition.value.type !== 'CallExpression' ||
+      propertyDefinition.value.callee.type !== 'MemberExpression') {
+    return false;
+  }
+
+  const isCalleeObjectThis = isMemberExpressionOnThis(propertyDefinition.value.callee);
+  // Whether the CallExpression is on a property of `this` (this.xxx.yyy.bind)
+  if (!isCalleeObjectThis) {
+    return false;
+  }
+
+  const isItBindCall = propertyDefinition.value.callee.property.name === 'bind';
+  // Whether the CallExpression is a `bind` call on a property of `this`
+  if (!isItBindCall) {
+    return false;
+  }
+
+  const callArgument = propertyDefinition.value.arguments[0];
+  // Call argument to `bind` is not `this`
+  if (!callArgument || callArgument.type !== 'ThisExpression') {
+    return false;
+  }
+
+  return true;
+}
+
+// Whether the property definition is arrow function like `#render = () => {}`
+function isPropertyDefinitionViaArrowFunction(propertyDefinition) {
+  return propertyDefinition.value.type === 'ArrowFunctionExpression';
+}
+
+module.exports = {
+  meta: {
+    type: 'problem',
+    docs: {
+      description: 'Enforce render method to be bound while calling scheduleRender',
+      category: 'Possible Errors',
+    },
+    fixable: 'code',
+    schema: []  // no options
+  },
+  create: function(context) {
+    return {
+      CallExpression(node) {
+        // Calls in the form of `ScheduledRender.scheduleRender`
+        const isScheduleRenderCall = node.callee.type === 'MemberExpression' &&
+            node.callee.object?.property?.name === 'ScheduledRender' && node.callee.property?.name === 'scheduleRender';
+        if (!isScheduleRenderCall) {
+          return;
+        }
+
+        const callbackArgument = node.arguments[1];
+        // Whether the second argument points to a property of `this`
+        // like `ScheduledRender.scheduleRender(<any>, this.<any>)
+        if (callbackArgument.type !== 'MemberExpression' || callbackArgument.object.type !== 'ThisExpression') {
+          return;
+        }
+
+        const containingClassForTheCall = goToClassDeclaration(node);
+        // Only care about the calls in custom components
+        if (!containingClassForTheCall.superClass || containingClassForTheCall.superClass.name !== 'HTMLElement') {
+          return;
+        }
+
+        const calledMethod = callbackArgument.property;
+        // Check whether the called method is bound (it should be 'PropertyDefinition')
+        const propertyDefinition = containingClassForTheCall.body.body.find(
+            bodyNode => bodyNode.type === 'PropertyDefinition' && bodyNode.key.name === calledMethod.name);
+        if (!propertyDefinition ||
+            (!isPropertyDefinitionViaArrowFunction(propertyDefinition) &&
+             !isPropertyDefinitionViaBindCallToThis(propertyDefinition))) {
+          context.report({node, message: 'Bind `render` method of `scheduleRender` to `this` in components'});
+        }
+      }
+    };
+  }
+};
diff --git a/scripts/eslint_rules/tests/enforce_bound_render_for_schedule_render_test.js b/scripts/eslint_rules/tests/enforce_bound_render_for_schedule_render_test.js
new file mode 100644
index 0000000..4e40d89
--- /dev/null
+++ b/scripts/eslint_rules/tests/enforce_bound_render_for_schedule_render_test.js
@@ -0,0 +1,74 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+'use strict';
+
+const rule = require('../lib/enforce_bound_render_for_schedule_render.js');
+const ruleTester = new (require('eslint').RuleTester)({
+  parserOptions: {ecmaVersion: 9, sourceType: 'module'},
+  parser: require.resolve('@typescript-eslint/parser'),
+});
+
+ruleTester.run('enforce_bound_render_for_schedule_render', rule, {
+  valid: [
+    {
+      code: `
+      class Component extends HTMLElement {
+        #boundRender = this.#render.bind(this);
+        get data(data) {
+          this.data = data;
+          void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#boundRender);
+        }
+
+        #render() {}
+      }
+      `,
+      filename: 'front_end/ui/components/foo/Foo.ts',
+    },
+    {
+      code: `
+      class Component extends HTMLElement {
+        get data(data) {
+          this.data = data;
+          void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render);
+        }
+
+        #render = () => {};
+      }
+      `,
+      filename: 'front_end/ui/components/foo/Foo.ts',
+    },
+    {
+      code: `
+      class Renderer {
+        render() {
+        }
+      }
+
+      class Component extends HTMLElement {
+        #renderer = new Renderer();
+        #boundRender = this.#renderer.render.bind(this);
+        get data(data) {
+          this.data = data;
+          void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#boundRender);
+        }
+      }
+      `,
+      filename: 'front_end/ui/components/foo/Foo.ts',
+    },
+  ],
+  invalid: [
+    {
+      code: `class Component extends HTMLElement {
+        get data(data) {
+          this.data = data;
+          void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render);
+        }
+
+        #render() {}
+      }`,
+      filename: 'front_end/components/test.ts',
+      errors: [{message: 'Bind `render` method of `scheduleRender` to `this` in components'}],
+    },
+  ]
+});