Repository: vuejs/eslint-plugin-vue (js)

Issue:
Rule proposal: `no-expose-after-await`
**Please describe what the rule should do:**

Similar to https://eslint.vuejs.org/rules/no-lifecycle-after-await.html and https://eslint.vuejs.org/rules/no-watch-after-await.html but applies to `expose` call. Because `expose` needs access to the current instance too.

**What category should the rule belong to?**

[ ] Enforces code style (layout)
[x] Warns about a potential error (problem)
[ ] Suggests an alternate way of doing something (suggestion)
[ ] Other (please specify:)

**Provide 2-3 code examples that this rule should warn about:**

```vue
<script>
import { defineComponent } from 'vue'

export defineComponent({
  setup(props, { expose }) {
    function sleep(ms) {
      return new Promise(resolve => setTimeout(resolve, ms));
	}
    await sleep(100)
    const sayHi = () = alert('hello')
    expose({ sayHi })
  }
})
</script>
```

**Additional context**
<!-- Add any other context or screenshots about the feature request here. -->



Pull request description:
Add `vue/no-expose-after-await` rule
This PR adds `vue/no-expose-after-await` rule that reports the `expose()` after `await` expression.  

close #1704

Patch:
diff --git a/docs/rules/README.md b/docs/rules/README.md
index 03ce9d58..5794cc3e 100644
--- a/docs/rules/README.md
+++ b/docs/rules/README.md
@@ -321,8 +321,10 @@ For example:
 | [vue/next-tick-style](./next-tick-style.md) | enforce Promise or callback style in `nextTick` | :wrench: |
 | [vue/no-bare-strings-in-template](./no-bare-strings-in-template.md) | disallow the use of bare strings in `<template>` |  |
 | [vue/no-boolean-default](./no-boolean-default.md) | disallow boolean defaults | :wrench: |
+| [vue/no-child-content](./no-child-content.md) | disallow element's child contents which would be overwritten by a directive like `v-html` or `v-text` | :bulb: |
 | [vue/no-duplicate-attr-inheritance](./no-duplicate-attr-inheritance.md) | enforce `inheritAttrs` to be set to `false` when using `v-bind="$attrs"` |  |
 | [vue/no-empty-component-block](./no-empty-component-block.md) | disallow the `<template>` `<script>` `<style>` block to be empty |  |
+| [vue/no-expose-after-await](./no-expose-after-await.md) | disallow asynchronously registered `expose` |  |
 | [vue/no-invalid-model-keys](./no-invalid-model-keys.md) | require valid keys in model option |  |
 | [vue/no-multiple-objects-in-class](./no-multiple-objects-in-class.md) | disallow to pass multiple objects into array to class |  |
 | [vue/no-potential-component-option-typo](./no-potential-component-option-typo.md) | disallow a potential typo in your component property | :bulb: |
diff --git a/docs/rules/no-child-content.md b/docs/rules/no-child-content.md
new file mode 100644
index 00000000..f3bf3177
--- /dev/null
+++ b/docs/rules/no-child-content.md
@@ -0,0 +1,53 @@
+---
+pageClass: rule-details
+sidebarDepth: 0
+title: vue/no-child-content
+description: disallow element's child contents which would be overwritten by a directive like `v-html` or `v-text`
+---
+# vue/no-child-content
+
+> disallow element's child contents which would be overwritten by a directive like `v-html` or `v-text`
+
+- :exclamation: <badge text="This rule has not been released yet." vertical="middle" type="error"> ***This rule has not been released yet.*** </badge>
+- :bulb: Some problems reported by this rule are manually fixable by editor [suggestions](https://eslint.org/docs/developer-guide/working-with-rules#providing-suggestions).
+
+## :book: Rule Details
+
+This rule reports child content of elements that have a directive which overwrites that child content. By default, those are `v-html` and `v-text`, additional ones (e.g. [Vue I18n's `v-t` directive](https://vue-i18n.intlify.dev/api/directive.html)) can be configured manually.
+
+<eslint-code-block :rules="{'vue/no-child-content': ['error']}">
+
+```vue
+<template>
+  <!-- ✓ GOOD -->
+  <div>child content</div>
+  <div v-html="replacesChildContent"></div>
+
+  <!-- ✗ BAD -->
+  <div v-html="replacesChildContent">child content</div>
+</template>
+```
+
+</eslint-code-block>
+
+## :wrench: Options
+
+```json
+{
+  "vue/no-child-content": ["error", {
+    "additionalDirectives": ["foo"] // checks v-foo directive
+  }]
+}
+```
+
+- `additionalDirectives` ... An array of additional directives to check, without the `v-` prefix. Empty by default; `v-html` and `v-text` are always checked.
+
+## :books: Further Reading
+
+- [`v-html` directive](https://v3.vuejs.org/api/directives.html#v-html)
+- [`v-text` directive](https://v3.vuejs.org/api/directives.html#v-text)
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/no-child-content.js)
+- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/no-child-content.js)
diff --git a/docs/rules/no-expose-after-await.md b/docs/rules/no-expose-after-await.md
new file mode 100644
index 00000000..9b7bc8e3
--- /dev/null
+++ b/docs/rules/no-expose-after-await.md
@@ -0,0 +1,51 @@
+---
+pageClass: rule-details
+sidebarDepth: 0
+title: vue/no-expose-after-await
+description: disallow asynchronously registered `expose`
+---
+# vue/no-expose-after-await
+
+> disallow asynchronously registered `expose`
+
+- :exclamation: <badge text="This rule has not been released yet." vertical="middle" type="error"> ***This rule has not been released yet.*** </badge>
+
+## :book: Rule Details
+
+This rule reports the `expose()` after `await` expression.  
+In `setup()` function, `expose()` should be registered synchronously.
+
+<eslint-code-block :rules="{'vue/no-expose-after-await': ['error']}">
+
+```vue
+<script>
+import { watch } from 'vue'
+export default {
+  async setup(props, { expose }) {
+    /* ✓ GOOD */
+    expose({/* ... */})
+
+    await doSomething()
+
+    /* ✗ BAD */
+    expose({/* ... */})
+  }
+}
+</script>
+```
+
+</eslint-code-block>
+
+## :wrench: Options
+
+Nothing.
+
+## :books: Further Reading
+
+- [Vue RFCs - 0042-expose-api](https://github.com/vuejs/rfcs/blob/master/active-rfcs/0042-expose-api.md)
+- [Vue RFCs - 0013-composition-api](https://github.com/vuejs/rfcs/blob/master/active-rfcs/0013-composition-api.md)
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/no-expose-after-await.js)
+- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/no-expose-after-await.js)
diff --git a/lib/index.js b/lib/index.js
index 60d7aac0..c9969917 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -58,6 +58,7 @@ module.exports = {
     'no-async-in-computed-properties': require('./rules/no-async-in-computed-properties'),
     'no-bare-strings-in-template': require('./rules/no-bare-strings-in-template'),
     'no-boolean-default': require('./rules/no-boolean-default'),
+    'no-child-content': require('./rules/no-child-content'),
     'no-computed-properties-in-data': require('./rules/no-computed-properties-in-data'),
     'no-confusing-v-for-v-if': require('./rules/no-confusing-v-for-v-if'),
     'no-constant-condition': require('./rules/no-constant-condition'),
@@ -88,6 +89,7 @@ module.exports = {
     'no-empty-component-block': require('./rules/no-empty-component-block'),
     'no-empty-pattern': require('./rules/no-empty-pattern'),
     'no-export-in-script-setup': require('./rules/no-export-in-script-setup'),
+    'no-expose-after-await': require('./rules/no-expose-after-await'),
     'no-extra-parens': require('./rules/no-extra-parens'),
     'no-invalid-model-keys': require('./rules/no-invalid-model-keys'),
     'no-irregular-whitespace': require('./rules/no-irregular-whitespace'),
diff --git a/lib/rules/no-child-content.js b/lib/rules/no-child-content.js
new file mode 100644
index 00000000..85636d1d
--- /dev/null
+++ b/lib/rules/no-child-content.js
@@ -0,0 +1,164 @@
+/**
+ * @author Flo Edelmann
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+const { defineTemplateBodyVisitor } = require('../utils')
+
+/**
+ * @typedef {object} RuleOption
+ * @property {string[]} additionalDirectives
+ */
+
+/**
+ * @param {VNode | Token} node
+ * @returns {boolean}
+ */
+function isWhiteSpaceTextNode(node) {
+  return node.type === 'VText' && node.value.trim() === ''
+}
+
+/**
+ * @param {Position} pos1
+ * @param {Position} pos2
+ * @returns {'less' | 'equal' | 'greater'}
+ */
+function comparePositions(pos1, pos2) {
+  if (
+    pos1.line < pos2.line ||
+    (pos1.line === pos2.line && pos1.column < pos2.column)
+  ) {
+    return 'less'
+  }
+
+  if (
+    pos1.line > pos2.line ||
+    (pos1.line === pos2.line && pos1.column > pos2.column)
+  ) {
+    return 'greater'
+  }
+
+  return 'equal'
+}
+
+/**
+ * @param {(VNode | Token)[]} nodes
+ * @returns {SourceLocation | undefined}
+ */
+function getLocationRange(nodes) {
+  /** @type {Position | undefined} */
+  let start
+  /** @type {Position | undefined} */
+  let end
+
+  for (const node of nodes) {
+    if (!start || comparePositions(node.loc.start, start) === 'less') {
+      start = node.loc.start
+    }
+
+    if (!end || comparePositions(node.loc.end, end) === 'greater') {
+      end = node.loc.end
+    }
+  }
+
+  if (start === undefined || end === undefined) {
+    return undefined
+  }
+
+  return { start, end }
+}
+
+// ------------------------------------------------------------------------------
+// Rule Definition
+// ------------------------------------------------------------------------------
+
+module.exports = {
+  meta: {
+    hasSuggestions: true,
+    type: 'problem',
+    docs: {
+      description:
+        "disallow element's child contents which would be overwritten by a directive like `v-html` or `v-text`",
+      categories: undefined,
+      url: 'https://eslint.vuejs.org/rules/no-child-content.html'
+    },
+    fixable: null,
+    schema: [
+      {
+        type: 'object',
+        additionalProperties: false,
+        properties: {
+          additionalDirectives: {
+            type: 'array',
+            uniqueItems: true,
+            minItems: 1,
+            items: {
+              type: 'string'
+            }
+          }
+        },
+        required: ['additionalDirectives']
+      }
+    ]
+  },
+  /** @param {RuleContext} context */
+  create(context) {
+    const directives = new Set(['html', 'text'])
+
+    /** @type {RuleOption | undefined} */
+    const option = context.options[0]
+    if (option !== undefined) {
+      for (const directive of option.additionalDirectives) {
+        directives.add(directive)
+      }
+    }
+
+    return defineTemplateBodyVisitor(context, {
+      /** @param {VDirective} directiveNode */
+      'VAttribute[directive=true]'(directiveNode) {
+        const directiveName = directiveNode.key.name.name
+        const elementNode = directiveNode.parent.parent
+
+        if (elementNode.endTag === null) {
+          return
+        }
+
+        const tokenStore = context.parserServices.getTemplateBodyTokenStore()
+        const elementComments = tokenStore.getTokensBetween(
+          elementNode.startTag,
+          elementNode.endTag,
+          {
+            includeComments: true,
+            filter: (token) => token.type === 'HTMLComment'
+          }
+        )
+
+        const childNodes = [...elementNode.children, ...elementComments]
+
+        if (
+          directives.has(directiveName) &&
+          childNodes.length > 0 &&
+          childNodes.some((childNode) => !isWhiteSpaceTextNode(childNode))
+        ) {
+          context.report({
+            node: elementNode,
+            loc: getLocationRange(childNodes),
+            message:
+              'Child content is disallowed because it will be overwritten by the v-{{ directiveName }} directive.',
+            data: { directiveName },
+            suggest: [
+              {
+                desc: 'Remove child content.',
+                *fix(fixer) {
+                  for (const childNode of childNodes) {
+                    yield fixer.remove(childNode)
+                  }
+                }
+              }
+            ]
+          })
+        }
+      }
+    })
+  }
+}
diff --git a/lib/rules/no-expose-after-await.js b/lib/rules/no-expose-after-await.js
new file mode 100644
index 00000000..e1e3104d
--- /dev/null
+++ b/lib/rules/no-expose-after-await.js
@@ -0,0 +1,202 @@
+/**
+ * @author Yosuke Ota
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+// ------------------------------------------------------------------------------
+// Requirements
+// ------------------------------------------------------------------------------
+
+const { findVariable } = require('eslint-utils')
+const utils = require('../utils')
+
+// ------------------------------------------------------------------------------
+// Helpers
+// ------------------------------------------------------------------------------
+
+/**
+ * Get the callee member node from the given CallExpression
+ * @param {CallExpression} node CallExpression
+ */
+function getCalleeMemberNode(node) {
+  const callee = utils.skipChainExpression(node.callee)
+
+  if (callee.type === 'MemberExpression') {
+    const name = utils.getStaticPropertyName(callee)
+    if (name) {
+      return { name, member: callee }
+    }
+  }
+  return null
+}
+
+// ------------------------------------------------------------------------------
+// Rule Definition
+// ------------------------------------------------------------------------------
+
+module.exports = {
+  meta: {
+    type: 'problem',
+    docs: {
+      description: 'disallow asynchronously registered `expose`',
+      categories: undefined,
+      // categories: ['vue3-essential'], TODO Change with the major version
+      url: 'https://eslint.vuejs.org/rules/no-expose-after-await.html'
+    },
+    fixable: null,
+    schema: [],
+    messages: {
+      forbidden: 'The `expose` after `await` expression are forbidden.'
+    }
+  },
+  /** @param {RuleContext} context */
+  create(context) {
+    /**
+     * @typedef {object} SetupScopeData
+     * @property {boolean} afterAwait
+     * @property {[number,number]} range
+     * @property {Set<Identifier>} exposeReferenceIds
+     * @property {Set<Identifier>} contextReferenceIds
+     */
+    /**
+     * @typedef {object} ScopeStack
+     * @property {ScopeStack | null} upper
+     * @property {FunctionDeclaration | FunctionExpression | ArrowFunctionExpression} scopeNode
+     */
+    /** @type {Map<FunctionDeclaration | FunctionExpression | ArrowFunctionExpression, SetupScopeData>} */
+    const setupScopes = new Map()
+
+    /** @type {ScopeStack | null} */
+    let scopeStack = null
+
+    return utils.defineVueVisitor(context, {
+      onSetupFunctionEnter(node) {
+        const contextParam = node.params[1]
+        if (!contextParam) {
+          // no arguments
+          return
+        }
+        if (contextParam.type === 'RestElement') {
+          // cannot check
+          return
+        }
+        if (contextParam.type === 'ArrayPattern') {
+          // cannot check
+          return
+        }
+        /** @type {Set<Identifier>} */
+        const contextReferenceIds = new Set()
+        /** @type {Set<Identifier>} */
+        const exposeReferenceIds = new Set()
+        if (contextParam.type === 'ObjectPattern') {
+          const exposeProperty = utils.findAssignmentProperty(
+            contextParam,
+            'expose'
+          )
+          if (!exposeProperty) {
+            return
+          }
+          const exposeParam = exposeProperty.value
+          // `setup(props, {emit})`
+          const variable =
+            exposeParam.type === 'Identifier'
+              ? findVariable(context.getScope(), exposeParam)
+              : null
+          if (!variable) {
+            return
+          }
+          for (const reference of variable.references) {
+            if (!reference.isRead()) {
+              continue
+            }
+            exposeReferenceIds.add(reference.identifier)
+          }
+        } else if (contextParam.type === 'Identifier') {
+          // `setup(props, context)`
+          const variable = findVariable(context.getScope(), contextParam)
+          if (!variable) {
+            return
+          }
+          for (const reference of variable.references) {
+            if (!reference.isRead()) {
+              continue
+            }
+            contextReferenceIds.add(reference.identifier)
+          }
+        }
+        setupScopes.set(node, {
+          afterAwait: false,
+          range: node.range,
+          exposeReferenceIds,
+          contextReferenceIds
+        })
+      },
+      /**
+       * @param {FunctionExpression | FunctionDeclaration | ArrowFunctionExpression} node
+       */
+      ':function'(node) {
+        scopeStack = {
+          upper: scopeStack,
+          scopeNode: node
+        }
+      },
+      ':function:exit'() {
+        scopeStack = scopeStack && scopeStack.upper
+      },
+      /** @param {AwaitExpression} node */
+      AwaitExpression(node) {
+        if (!scopeStack) {
+          return
+        }
+        const setupScope = setupScopes.get(scopeStack.scopeNode)
+        if (!setupScope || !utils.inRange(setupScope.range, node)) {
+          return
+        }
+        setupScope.afterAwait = true
+      },
+      /** @param {CallExpression} node */
+      CallExpression(node) {
+        if (!scopeStack) {
+          return
+        }
+        const setupScope = setupScopes.get(scopeStack.scopeNode)
+        if (
+          !setupScope ||
+          !setupScope.afterAwait ||
+          !utils.inRange(setupScope.range, node)
+        ) {
+          return
+        }
+        const { contextReferenceIds, exposeReferenceIds } = setupScope
+        if (
+          node.callee.type === 'Identifier' &&
+          exposeReferenceIds.has(node.callee)
+        ) {
+          // setup(props,{expose}) {expose()}
+          context.report({
+            node,
+            messageId: 'forbidden'
+          })
+        } else {
+          const expose = getCalleeMemberNode(node)
+          if (
+            expose &&
+            expose.name === 'expose' &&
+            expose.member.object.type === 'Identifier' &&
+            contextReferenceIds.has(expose.member.object)
+          ) {
+            // setup(props,context) {context.emit()}
+            context.report({
+              node,
+              messageId: 'forbidden'
+            })
+          }
+        }
+      },
+      onSetupFunctionExit(node) {
+        setupScopes.delete(node)
+      }
+    })
+  }
+}
diff --git a/lib/utils/index.js b/lib/utils/index.js
index ef82c7b9..d6c3a6f5 100644
--- a/lib/utils/index.js
+++ b/lib/utils/index.js
@@ -1695,7 +1695,7 @@ module.exports = {
   /**
    * Checks whether the target node is within the given range.
    * @param { [number, number] } range
-   * @param {ASTNode} target
+   * @param {ASTNode | Token} target
    */
   inRange(range, target) {
     return range[0] <= target.range[0] && target.range[1] <= range[1]


Test patch:
diff --git a/tests/lib/rules/no-child-content.js b/tests/lib/rules/no-child-content.js
new file mode 100644
index 00000000..320b7ade
--- /dev/null
+++ b/tests/lib/rules/no-child-content.js
@@ -0,0 +1,224 @@
+/**
+ * @author Flo Edelmann
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+// ------------------------------------------------------------------------------
+// Requirements
+// ------------------------------------------------------------------------------
+
+const RuleTester = require('eslint').RuleTester
+const rule = require('../../../lib/rules/no-child-content')
+
+// ------------------------------------------------------------------------------
+// Tests
+// ------------------------------------------------------------------------------
+const ruleTester = new RuleTester({
+  parser: require.resolve('vue-eslint-parser'),
+  parserOptions: { ecmaVersion: 2015 }
+})
+
+ruleTester.run('no-child-content', rule, {
+  valid: [
+    {
+      // element without directive
+      filename: 'test.vue',
+      code: '<template><div>foo</div></template>'
+    },
+    {
+      // element with unknown directive
+      filename: 'test.vue',
+      code: '<template><div v-foo="bar" /></template>'
+    },
+    {
+      // self-closing element with v-html directive
+      filename: 'test.vue',
+      code: '<template><div v-html="foo" /></template>'
+    },
+    {
+      // self-closing element with v-text directive
+      filename: 'test.vue',
+      code: '<template><div v-text="foo" /></template>'
+    },
+    {
+      // empty element with v-html directive
+      filename: 'test.vue',
+      code: '<template><div v-html="foo"></div></template>'
+    },
+    {
+      // v-html directive and whitespace-only text content
+      filename: 'test.vue',
+      code: '<template><div v-html="foo"> </div></template>'
+    },
+    {
+      // v-html directive and whitespace-only text content with newline
+      filename: 'test.vue',
+      code: `
+        <template>
+          <div v-html="foo">
+          </div>
+        </template>
+      `
+    },
+    {
+      // self-closing element with v-t directive
+      filename: 'test.vue',
+      options: [{ additionalDirectives: ['t'] }],
+      code: '<template><div v-t="foo" /></template>'
+    },
+    {
+      // self-closing element with v-html directive and sibling comment element
+      filename: 'test.vue',
+      code: '<template><div v-html="foo" /><!-- bar --></template>'
+    }
+  ],
+  invalid: [
+    {
+      // v-html directive and text content
+      filename: 'test.vue',
+      code: '<template><div v-html="foo">bar</div></template>',
+      errors: [
+        {
+          message:
+            'Child content is disallowed because it will be overwritten by the v-html directive.',
+          column: 29,
+          endColumn: 32,
+          suggestions: [
+            { output: '<template><div v-html="foo"></div></template>' }
+          ]
+        }
+      ]
+    },
+    {
+      // v-html directive and text expression content
+      filename: 'test.vue',
+      code: '<template><div v-html="foo">{{ bar }}</div></template>',
+      errors: [
+        {
+          message:
+            'Child content is disallowed because it will be overwritten by the v-html directive.',
+          column: 29,
+          endColumn: 38,
+          suggestions: [
+            { output: '<template><div v-html="foo"></div></template>' }
+          ]
+        }
+      ]
+    },
+    {
+      // v-html directive and child element
+      filename: 'test.vue',
+      code: '<template><div v-html="foo"><span /></div></template>',
+      errors: [
+        {
+          message:
+            'Child content is disallowed because it will be overwritten by the v-html directive.',
+          column: 29,
+          endColumn: 37,
+          suggestions: [
+            { output: '<template><div v-html="foo"></div></template>' }
+          ]
+        }
+      ]
+    },
+    {
+      // v-html directive and child comment element
+      filename: 'test.vue',
+      code: '<template><div v-html="foo"><!-- bar --></div></template>',
+      errors: [
+        {
+          message:
+            'Child content is disallowed because it will be overwritten by the v-html directive.',
+          column: 29,
+          endColumn: 41,
+          suggestions: [
+            { output: '<template><div v-html="foo"></div></template>' }
+          ]
+        }
+      ]
+    },
+    {
+      // v-html directive and multiple child elements
+      filename: 'test.vue',
+      code: `
+        <template>
+          <div v-html="foo">
+            <!-- this is a test -->
+            <span>foo</span>
+            bar
+          </div>
+        </template>
+      `,
+      errors: [
+        {
+          message:
+            'Child content is disallowed because it will be overwritten by the v-html directive.',
+          line: 3,
+          column: 29,
+          endLine: 7,
+          endColumn: 11,
+          suggestions: [
+            {
+              output: `
+        <template>
+          <div v-html="foo"></div>
+        </template>
+      `
+            }
+          ]
+        }
+      ]
+    },
+    {
+      // v-text directive and text content
+      filename: 'test.vue',
+      code: '<template><div v-text="foo">bar</div></template>',
+      errors: [
+        {
+          message:
+            'Child content is disallowed because it will be overwritten by the v-text directive.',
+          column: 29,
+          endColumn: 32,
+          suggestions: [
+            { output: '<template><div v-text="foo"></div></template>' }
+          ]
+        }
+      ]
+    },
+    {
+      // v-t directive and text content
+      filename: 'test.vue',
+      options: [{ additionalDirectives: ['t'] }],
+      code: '<template><div v-t="foo">bar</div></template>',
+      errors: [
+        {
+          message:
+            'Child content is disallowed because it will be overwritten by the v-t directive.',
+          column: 26,
+          endColumn: 29,
+          suggestions: [
+            { output: '<template><div v-t="foo"></div></template>' }
+          ]
+        }
+      ]
+    },
+    {
+      // v-html directive and text content while v-t directive is configured
+      filename: 'test.vue',
+      options: [{ additionalDirectives: ['t'] }],
+      code: '<template><div v-html="foo">baz</div></template>',
+      errors: [
+        {
+          message:
+            'Child content is disallowed because it will be overwritten by the v-html directive.',
+          column: 29,
+          endColumn: 32,
+          suggestions: [
+            { output: '<template><div v-html="foo"></div></template>' }
+          ]
+        }
+      ]
+    }
+  ]
+})
diff --git a/tests/lib/rules/no-expose-after-await.js b/tests/lib/rules/no-expose-after-await.js
new file mode 100644
index 00000000..2f33c58e
--- /dev/null
+++ b/tests/lib/rules/no-expose-after-await.js
@@ -0,0 +1,152 @@
+/**
+ * @author Yosuke Ota
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+const RuleTester = require('eslint').RuleTester
+const rule = require('../../../lib/rules/no-expose-after-await')
+
+const tester = new RuleTester({
+  parser: require.resolve('vue-eslint-parser'),
+  parserOptions: {
+    ecmaVersion: 2020,
+    sourceType: 'module'
+  }
+})
+
+tester.run('no-expose-after-await', rule, {
+  valid: [
+    {
+      filename: 'test.vue',
+      code: `
+      <script>
+      export default {
+        async setup(_, {expose}) {
+          expose({ /* ... */ }) // ok
+          await doSomething()
+        }
+      }
+      </script>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script>
+      export default {
+        async setup(_, ctx) {
+          ctx.expose({ /* ... */ }) // ok
+          await doSomething()
+        }
+      }
+      </script>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script>
+      export default {
+        async setup(_, {expose}) {
+          expose({ /* ... */ })
+        }
+      }
+      </script>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script>
+      export default {
+        async setup(_, ctx) {
+          ctx.expose({ /* ... */ })
+        }
+      }
+      </script>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script>
+      export default {
+        async _setup(_, {expose}) {
+          expose({ /* ... */ })
+          await doSomething()
+          expose({ /* ... */ })
+        }
+      }
+      </script>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script>
+      export default {
+        async _setup(_, ctx) {
+          ctx.expose({ /* ... */ })
+          await doSomething()
+          ctx.expose({ /* ... */ })
+        }
+      }
+      </script>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script setup>
+      defineExpose({ /* ... */ })
+      await doSomething()
+      defineExpose({ /* ... */ })
+      </script>
+      `,
+      parserOptions: { ecmaVersion: 2022 }
+    }
+  ],
+  invalid: [
+    {
+      filename: 'test.vue',
+      code: `
+      <script>
+      export default {
+        async setup(_, {expose}) {
+          await doSomething()
+          expose({ /* ... */ })
+        }
+      }
+      </script>
+      `,
+      errors: [
+        {
+          message: 'The `expose` after `await` expression are forbidden.',
+          line: 6,
+          column: 11
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script>
+      export default {
+        async setup(_, ctx) {
+          await doSomething()
+          ctx.expose({ /* ... */ })
+        }
+      }
+      </script>
+      `,
+      errors: [
+        {
+          message: 'The `expose` after `await` expression are forbidden.',
+          line: 6,
+          column: 11
+        }
+      ]
+    }
+  ]
+})