Repository: ota-meshi/eslint-plugin-svelte
Pull request #270 (2022-10-05 06:33:38)

feat: add `svelte/prefer-destructured-store-props` rule
This PR is a follow up for #208.

Changed the rule to only warn about store property access in templates.

close #208

changeset-bot[bot]: ###  🦋  Changeset detected

Latest commit: 2306947b97469072fbf515cfd5226f814801d37a

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 1 package</summary>

  | Name                 | Type  |
| -------------------- | ----- |
| eslint-plugin-svelte | Minor |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/master/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/ota-meshi/eslint-plugin-svelte/new/prefer-destructured-store-props?filename=.changeset/rude-pugs-beam.md&value=---%0A%22eslint-plugin-svelte%22%3A%20patch%0A---%0A%0Afeat%3A%20add%20%60svelte%2Fprefer-destructured-store-props%60%20rule%0A)


codesandbox-ci[bot]: This pull request is automatically built and testable in [CodeSandbox](https://codesandbox.io).

  To see build info of the built libraries, click [here](https://ci.codesandbox.io/status/ota-meshi/eslint-plugin-svelte/pr/270/builds/301141) or the icon next to each commit SHA.

Patch:
diff --git a/.changeset/real-buses-complain.md b/.changeset/real-buses-complain.md
new file mode 100644
index 0000000..d69feb2
--- /dev/null
+++ b/.changeset/real-buses-complain.md
@@ -0,0 +1,5 @@
+---
+"eslint-plugin-svelte": minor
+---
+
+feat: add `svelte/prefer-destructured-store-props` rule
diff --git a/.eslintrc.js b/.eslintrc.js
index 69840b3..2d1ef65 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -50,6 +50,7 @@ module.exports = {
     },
     {
       files: ["*.svelte"],
+      extends: ["plugin:svelte/base"],
       parser: "svelte-eslint-parser",
       parserOptions: {
         parser: {
diff --git a/README.md b/README.md
index 20aff84..8485e59 100644
--- a/README.md
+++ b/README.md
@@ -294,6 +294,7 @@ These rules relate to better ways of doing things to help you avoid problems:
 | [svelte/no-reactive-literals](https://ota-meshi.github.io/eslint-plugin-svelte/rules/no-reactive-literals/) | don't assign literal values in reactive statements | :bulb: |
 | [svelte/no-unused-svelte-ignore](https://ota-meshi.github.io/eslint-plugin-svelte/rules/no-unused-svelte-ignore/) | disallow unused svelte-ignore comments | :star: |
 | [svelte/no-useless-mustaches](https://ota-meshi.github.io/eslint-plugin-svelte/rules/no-useless-mustaches/) | disallow unnecessary mustache interpolations | :wrench: |
+| [svelte/prefer-destructured-store-props](https://ota-meshi.github.io/eslint-plugin-svelte/rules/prefer-destructured-store-props/) | destructure values from object stores for better change tracking & fewer redraws | :bulb: |
 | [svelte/require-optimized-style-attribute](https://ota-meshi.github.io/eslint-plugin-svelte/rules/require-optimized-style-attribute/) | require style attributes that can be optimized |  |
 | [svelte/require-stores-init](https://ota-meshi.github.io/eslint-plugin-svelte/rules/require-stores-init/) | require initial value in store |  |
 
diff --git a/docs-svelte-kit/src/lib/eslint/ESLintEditor.svelte b/docs-svelte-kit/src/lib/eslint/ESLintEditor.svelte
index f269ce5..2334717 100644
--- a/docs-svelte-kit/src/lib/eslint/ESLintEditor.svelte
+++ b/docs-svelte-kit/src/lib/eslint/ESLintEditor.svelte
@@ -219,10 +219,11 @@
         edits: [
           {
             resource: model.uri,
-            edit: {
+            textEdit: {
               range: editRange,
               text: fix.text,
             },
+            versionId: model.getVersionId(),
           },
         ],
       },
diff --git a/docs-svelte-kit/src/lib/eslint/MonacoEditor.svelte b/docs-svelte-kit/src/lib/eslint/MonacoEditor.svelte
index 5e1901b..5aa2f48 100644
--- a/docs-svelte-kit/src/lib/eslint/MonacoEditor.svelte
+++ b/docs-svelte-kit/src/lib/eslint/MonacoEditor.svelte
@@ -58,7 +58,7 @@
           monaco.languages.registerCodeActionProvider(language, {
             provideCodeActions(model, range, context) {
               const editor = getLeftEditor?.()
-              if (editor?.getModel().url !== model.url) {
+              if (editor?.getModel().uri !== model.uri) {
                 return {
                   actions: [],
                   dispose() {
diff --git a/docs/rules.md b/docs/rules.md
index 2a5fb4e..df88e4f 100644
--- a/docs/rules.md
+++ b/docs/rules.md
@@ -47,6 +47,7 @@ These rules relate to better ways of doing things to help you avoid problems:
 | [svelte/no-reactive-literals](./rules/no-reactive-literals.md) | don't assign literal values in reactive statements | :bulb: |
 | [svelte/no-unused-svelte-ignore](./rules/no-unused-svelte-ignore.md) | disallow unused svelte-ignore comments | :star: |
 | [svelte/no-useless-mustaches](./rules/no-useless-mustaches.md) | disallow unnecessary mustache interpolations | :wrench: |
+| [svelte/prefer-destructured-store-props](./rules/prefer-destructured-store-props.md) | destructure values from object stores for better change tracking & fewer redraws | :bulb: |
 | [svelte/require-optimized-style-attribute](./rules/require-optimized-style-attribute.md) | require style attributes that can be optimized |  |
 | [svelte/require-stores-init](./rules/require-stores-init.md) | require initial value in store |  |
 
diff --git a/docs/rules/prefer-destructured-store-props.md b/docs/rules/prefer-destructured-store-props.md
new file mode 100644
index 0000000..bf40c55
--- /dev/null
+++ b/docs/rules/prefer-destructured-store-props.md
@@ -0,0 +1,55 @@
+---
+pageClass: "rule-details"
+sidebarDepth: 0
+title: "svelte/prefer-destructured-store-props"
+description: "destructure values from object stores for better change tracking & fewer redraws"
+---
+
+# svelte/prefer-destructured-store-props
+
+> destructure values from object stores for better change tracking & fewer redraws
+
+- :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 on directly accessing properties of a store containing an object in templates. These usages can instead be written as a reactive statement using destructuring to allow for more granular change-tracking and reduced redraws in the component.
+
+An example of the improvements can be see in this [REPL](https://svelte.dev/repl/7de86fea94ff40c48abb82da534dfb89)
+
+<ESLintCodeBlock>
+
+<!--eslint-skip-->
+
+```svelte
+<script>
+  /* eslint svelte/prefer-destructured-store-props: "error" */
+  import store from "./store.js"
+  $: ({ foo } = $store)
+</script>
+
+<!-- ✓ GOOD -->
+{foo}
+
+<!-- ✗ BAD -->
+{$store.foo}
+```
+
+</ESLintCodeBlock>
+
+## :wrench: Options
+
+Nothing
+
+## :heart: Compatibility
+
+This rule was taken from [@tivac/eslint-plugin-svelte].  
+This rule is compatible with `@tivac/svelte/store-prop-destructuring` rule.
+
+[@tivac/eslint-plugin-svelte]: https://github.com/tivac/eslint-plugin-svelte/
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/ota-meshi/eslint-plugin-svelte/blob/main/src/rules/prefer-destructured-store-props.ts)
+- [Test source](https://github.com/ota-meshi/eslint-plugin-svelte/blob/main/tests/src/rules/prefer-destructured-store-props.ts)
diff --git a/package.json b/package.json
index a50fcd7..93b6770 100644
--- a/package.json
+++ b/package.json
@@ -67,6 +67,7 @@
   "dependencies": {
     "debug": "^4.3.1",
     "eslint-utils": "^3.0.0",
+    "esutils": "^2.0.3",
     "known-css-properties": "^0.25.0",
     "postcss": "^8.4.5",
     "postcss-load-config": "^3.1.4",
@@ -100,6 +101,7 @@
     "@types/eslint-utils": "^3.0.1",
     "@types/eslint-visitor-keys": "^1.0.0",
     "@types/estree": "^1.0.0",
+    "@types/esutils": "^2.0.0",
     "@types/less": "^3.0.3",
     "@types/markdown-it": "^12.2.3",
     "@types/markdown-it-container": "^2.0.5",
@@ -172,7 +174,7 @@
     "access": "public"
   },
   "typeCoverage": {
-    "atLeast": 98.64,
+    "atLeast": 98.71,
     "cache": true,
     "detail": true,
     "ignoreAsAssertion": true,
diff --git a/src/rules/prefer-destructured-store-props.ts b/src/rules/prefer-destructured-store-props.ts
new file mode 100644
index 0000000..6bade7e
--- /dev/null
+++ b/src/rules/prefer-destructured-store-props.ts
@@ -0,0 +1,280 @@
+import type { TSESTree } from "@typescript-eslint/types"
+import { getPropertyName } from "eslint-utils"
+import type { AST } from "svelte-eslint-parser"
+import { keyword } from "esutils"
+import type { SuggestionReportDescriptor } from "../types"
+import { createRule } from "../utils"
+import {
+  findAttribute,
+  isExpressionIdentifier,
+  findVariable,
+} from "../utils/ast-utils"
+
+type StoreMemberExpression = TSESTree.MemberExpression & {
+  object: TSESTree.Identifier & { name: string }
+}
+
+export default createRule("prefer-destructured-store-props", {
+  meta: {
+    docs: {
+      description:
+        "destructure values from object stores for better change tracking & fewer redraws",
+      category: "Best Practices",
+      recommended: false,
+    },
+    hasSuggestions: true,
+    schema: [],
+    messages: {
+      useDestructuring: `Destructure {{property}} from {{store}} for better change tracking & fewer redraws`,
+      fixUseDestructuring: `Using destructuring like $: ({ {{property}} } = {{store}}); will run faster`,
+      fixUseVariable: `Using the predefined reactive variable {{variable}}`,
+    },
+    type: "suggestion",
+  },
+  create(context) {
+    let mainScript: AST.SvelteScriptElement | null = null
+
+    // Store off instances of probably-destructurable statements
+    const reports: StoreMemberExpression[] = []
+    let inScriptElement = false
+
+    const storeMemberAccessStack: {
+      node: StoreMemberExpression
+      // A list of Identifiers that make up the member expression.
+      identifiers: TSESTree.Identifier[]
+    }[] = []
+
+    /** Find for defined reactive variables. */
+    function* findReactiveVariable(
+      object: TSESTree.Identifier,
+      propName: string,
+    ): Iterable<TSESTree.Identifier> {
+      const storeVar = findVariable(context, object)
+      if (!storeVar) {
+        return
+      }
+
+      for (const reference of storeVar.references) {
+        const id = reference.identifier as TSESTree.Identifier
+        if (id.name !== object.name) continue
+        if (isReactiveVariableDefinitionWithMemberExpression(id)) {
+          // $: target = $store.prop
+          yield id.parent.parent.left
+        } else if (isReactiveVariableDefinitionWithDestructuring(id)) {
+          const prop = id.parent.left.properties.find(
+            (
+              prop,
+            ): prop is TSESTree.Property & { value: TSESTree.Identifier } =>
+              prop.type === "Property" &&
+              prop.value.type === "Identifier" &&
+              getPropertyName(prop) === propName,
+          )
+          if (prop) {
+            // $: ({prop: target} = $store)
+            yield prop.value
+          }
+        }
+      }
+
+      /** Checks whether the given node is reactive variable definition with member expression. */
+      function isReactiveVariableDefinitionWithMemberExpression(
+        node: TSESTree.Identifier,
+      ): node is TSESTree.Identifier & {
+        parent: TSESTree.MemberExpression & {
+          parent: TSESTree.AssignmentExpression & { left: TSESTree.Identifier }
+        }
+      } {
+        return (
+          node.parent?.type === "MemberExpression" &&
+          node.parent.object === node &&
+          getPropertyName(node.parent) === propName &&
+          node.parent.parent?.type === "AssignmentExpression" &&
+          node.parent.parent.right === node.parent &&
+          node.parent.parent.left.type === "Identifier" &&
+          node.parent.parent.parent?.type === "ExpressionStatement" &&
+          (
+            node.parent.parent.parent
+              .parent as never as AST.SvelteReactiveStatement
+          )?.type === "SvelteReactiveStatement"
+        )
+      }
+
+      /** Checks whether the given node is reactive variable definition with destructuring. */
+      function isReactiveVariableDefinitionWithDestructuring(
+        node: TSESTree.Identifier,
+      ): node is TSESTree.Identifier & {
+        parent: TSESTree.AssignmentExpression & {
+          left: TSESTree.ObjectPattern
+        }
+      } {
+        return (
+          node.parent?.type === "AssignmentExpression" &&
+          node.parent.right === node &&
+          node.parent.left.type === "ObjectPattern" &&
+          node.parent.parent?.type === "ExpressionStatement" &&
+          (node.parent.parent.parent as never as AST.SvelteReactiveStatement)
+            ?.type === "SvelteReactiveStatement"
+        )
+      }
+    }
+
+    /** Checks whether the given name is already defined as a variable. */
+    function hasTopLevelVariable(name: string) {
+      const scopeManager = context.getSourceCode().scopeManager
+      if (scopeManager.globalScope?.set.has(name)) {
+        return true
+      }
+      const moduleScope = scopeManager.globalScope?.childScopes.find(
+        (s) => s.type === "module",
+      )
+      return moduleScope?.set.has(name) || false
+    }
+
+    return {
+      SvelteScriptElement(node) {
+        inScriptElement = true
+        const scriptContext = findAttribute(node, "context")
+        const contextValue =
+          scriptContext?.value.length === 1 && scriptContext.value[0]
+        if (
+          contextValue &&
+          contextValue.type === "SvelteLiteral" &&
+          contextValue.value === "module"
+        ) {
+          // It is <script context="module">
+          return
+        }
+        mainScript = node
+      },
+      "SvelteScriptElement:exit"() {
+        inScriptElement = false
+      },
+
+      // {$foo.bar}
+      // should be
+      // $: ({ bar } = $foo);
+      // {bar}
+      // Same with {$foo["bar"]}
+      "MemberExpression[object.type='Identifier'][object.name=/^\\$/]"(
+        node: StoreMemberExpression,
+      ) {
+        if (inScriptElement) return // Within a script tag
+        storeMemberAccessStack.unshift({ node, identifiers: [] })
+      },
+      Identifier(node: TSESTree.Identifier) {
+        storeMemberAccessStack[0]?.identifiers.push(node)
+      },
+      "MemberExpression[object.type='Identifier'][object.name=/^\\$/]:exit"(
+        node: StoreMemberExpression,
+      ) {
+        if (storeMemberAccessStack[0]?.node !== node) return
+        const { identifiers } = storeMemberAccessStack.shift()!
+
+        for (const id of identifiers) {
+          if (!isExpressionIdentifier(id)) continue
+          const variable = findVariable(context, id)
+          const isTopLevel =
+            !variable ||
+            variable.scope.type === "module" ||
+            variable.scope.type === "global"
+          if (!isTopLevel) {
+            // Member expressions may use variables defined with {#each} etc.
+            return
+          }
+        }
+        reports.push(node)
+      },
+
+      "Program:exit"() {
+        const scriptEndTag = mainScript && mainScript.endTag
+        for (const node of reports) {
+          const store = node.object.name
+
+          const suggest: SuggestionReportDescriptor[] = []
+          if (
+            // Avoid suggestions for:
+            //  dynamic accesses like {$foo[bar]}
+            !node.computed
+          ) {
+            for (const variable of findReactiveVariable(
+              node.object,
+              node.property.name,
+            )) {
+              suggest.push({
+                messageId: "fixUseVariable",
+                data: {
+                  variable: variable.name,
+                },
+
+                fix(fixer) {
+                  return fixer.replaceText(node, variable.name)
+                },
+              })
+            }
+
+            if (
+              // Avoid suggestions for:
+              //  no <script> tag
+              //  no <script> ending
+              scriptEndTag
+            ) {
+              suggest.push({
+                messageId: "fixUseDestructuring",
+                data: {
+                  store,
+                  property: node.property.name,
+                },
+
+                fix(fixer) {
+                  const propName = node.property.name
+
+                  let varName = propName
+                  if (varName.startsWith("$")) {
+                    varName = varName.slice(1)
+                  }
+                  const baseName = varName
+                  let suffix = 0
+                  if (
+                    keyword.isReservedWordES6(varName, true) ||
+                    keyword.isRestrictedWord(varName)
+                  ) {
+                    varName = `${baseName}${++suffix}`
+                  }
+                  while (hasTopLevelVariable(varName)) {
+                    varName = `${baseName}${++suffix}`
+                  }
+
+                  return [
+                    fixer.insertTextAfterRange(
+                      [scriptEndTag.range[0], scriptEndTag.range[0]],
+                      `$: ({ ${propName}${
+                        propName !== varName ? `: ${varName}` : ""
+                      } } = ${store});\n`,
+                    ),
+                    fixer.replaceText(node, varName),
+                  ]
+                },
+              })
+            }
+          }
+
+          context.report({
+            node,
+            messageId: "useDestructuring",
+            data: {
+              store,
+              property: !node.computed
+                ? node.property.name
+                : context
+                    .getSourceCode()
+                    .getText(node.property)
+                    .replace(/\s+/g, " "),
+            },
+
+            suggest,
+          })
+        }
+      },
+    }
+  },
+})
diff --git a/src/types.ts b/src/types.ts
index 5565f79..d6401e7 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -168,7 +168,7 @@ interface ReportDescriptorOptionsBase {
 }
 
 type SuggestionDescriptorMessage = { desc: string } | { messageId: string }
-type SuggestionReportDescriptor = SuggestionDescriptorMessage &
+export type SuggestionReportDescriptor = SuggestionDescriptorMessage &
   ReportDescriptorOptionsBase
 
 interface ReportDescriptorOptions extends ReportDescriptorOptionsBase {
diff --git a/src/utils/ast-utils.ts b/src/utils/ast-utils.ts
index 66ebdc8..dd99f53 100644
--- a/src/utils/ast-utils.ts
+++ b/src/utils/ast-utils.ts
@@ -1,5 +1,6 @@
 import type { ASTNode, RuleContext, SourceCode } from "../types"
 import type * as ESTree from "estree"
+import type { TSESTree } from "@typescript-eslint/types"
 import type { AST as SvAST } from "svelte-eslint-parser"
 import * as eslintUtils from "eslint-utils"
 import type { Scope } from "eslint"
@@ -38,7 +39,9 @@ export function equalTokens(
 /**
  * Get the value of a given node if it's a literal or a template literal.
  */
-export function getStringIfConstant(node: ESTree.Expression): string | null {
+export function getStringIfConstant(
+  node: ESTree.Expression | TSESTree.Expression,
+): string | null {
   if (node.type === "Literal") {
     if (typeof node.value === "string") return node.value
   } else if (node.type === "TemplateLiteral") {
@@ -234,9 +237,21 @@ export function getLangValue(
  */
 export function findVariable(
   context: RuleContext,
-  node: ESTree.Identifier,
+  node: ESTree.Identifier | TSESTree.Identifier,
 ): Scope.Variable | null {
-  return eslintUtils.findVariable(getScope(context, node), node)
+  const initialScope = eslintUtils.getInnermostScope(
+    getScope(context, node),
+    node,
+  )
+  const variable = eslintUtils.findVariable(initialScope, node)
+  if (variable) {
+    return variable
+  }
+  if (!node.name.startsWith("$")) {
+    return variable
+  }
+  // Remove the $ and search for the variable again, as it may be a store access variable.
+  return eslintUtils.findVariable(initialScope, node.name.slice(1))
 }
 
 /**
@@ -522,3 +537,51 @@ export function getNodeName(node: SvAST.SvelteElement): string {
 export function isVoidHtmlElement(node: SvAST.SvelteElement): boolean {
   return voidElements.includes(getNodeName(node))
 }
+
+/* eslint-disable complexity -- better than refactoring because type guards work well. */
+/** Checks whether the given identifier node is used as an expression. */
+export function isExpressionIdentifier(
+  /* eslint-enable complexity -- :) */
+  node: TSESTree.Identifier,
+): boolean {
+  const parent = node.parent
+  if (!parent) {
+    return true
+  }
+  if (parent.type === "MemberExpression") {
+    return !parent.computed || parent.property !== node
+  }
+  if (
+    parent.type === "Property" ||
+    parent.type === "MethodDefinition" ||
+    parent.type === "PropertyDefinition"
+  ) {
+    return !parent.computed || parent.key !== node
+  }
+  if (
+    parent.type === "FunctionDeclaration" ||
+    parent.type === "FunctionExpression" ||
+    parent.type === "ClassDeclaration" ||
+    parent.type === "ClassExpression"
+  ) {
+    return parent.id !== node
+  }
+  if (
+    parent.type === "LabeledStatement" ||
+    parent.type === "BreakStatement" ||
+    parent.type === "ContinueStatement"
+  ) {
+    return parent.label !== node
+  }
+  if (parent.type === "MetaProperty") {
+    return parent.property !== node
+  }
+  if (parent.type === "ImportSpecifier") {
+    return parent.imported !== node
+  }
+  if (parent.type === "ExportSpecifier") {
+    return parent.exported !== node
+  }
+
+  return true
+}
diff --git a/src/utils/rules.ts b/src/utils/rules.ts
index 05199ab..d4157c2 100644
--- a/src/utils/rules.ts
+++ b/src/utils/rules.ts
@@ -30,6 +30,7 @@ import noUnknownStyleDirectiveProperty from "../rules/no-unknown-style-directive
 import noUnusedSvelteIgnore from "../rules/no-unused-svelte-ignore"
 import noUselessMustaches from "../rules/no-useless-mustaches"
 import preferClassDirective from "../rules/prefer-class-directive"
+import preferDestructuredStoreProps from "../rules/prefer-destructured-store-props"
 import preferStyleDirective from "../rules/prefer-style-directive"
 import requireOptimizedStyleAttribute from "../rules/require-optimized-style-attribute"
 import requireStoresInit from "../rules/require-stores-init"
@@ -72,6 +73,7 @@ export const rules = [
   noUnusedSvelteIgnore,
   noUselessMustaches,
   preferClassDirective,
+  preferDestructuredStoreProps,
   preferStyleDirective,
   requireOptimizedStyleAttribute,
   requireStoresInit,
diff --git a/typings/eslint-utils/index.d.ts b/typings/eslint-utils/index.d.ts
index fc14c75..944e272 100644
--- a/typings/eslint-utils/index.d.ts
+++ b/typings/eslint-utils/index.d.ts
@@ -1,9 +1,12 @@
 import type { AST } from "svelte-eslint-parser"
 import type { Scope } from "eslint"
 import type * as ESTree from "estree"
+import type { TSESTree } from "@typescript-eslint/types"
 export {
   ReferenceTracker,
   TrackedReferences,
+  getPropertyName,
+  getInnermostScope,
 } from "../../node_modules/@types/eslint-utils"
 type Token = { type: string; value: string }
 export function isArrowToken(token: Token): boolean
@@ -31,5 +34,21 @@ export function isNotCommentToken(token: Token): boolean
 
 export function findVariable(
   initialScope: Scope.Scope,
-  nameOrNode: ESTree.Identifier | string,
+  nameOrNode: ESTree.Identifier | TSESTree.Identifier | string,
 ): Scope.Variable
+
+/**
+ * Get the property name from a MemberExpression node or a Property node.
+ */
+export function getPropertyName(
+  node:
+    | ESTree.MemberExpression
+    | ESTree.MethodDefinition
+    | ESTree.Property
+    | ESTree.PropertyDefinition
+    | TSESTree.MemberExpression
+    | TSESTree.MethodDefinition
+    | TSESTree.Property
+    | TSESTree.PropertyDefinition,
+  initialScope?: Scope.Scope,
+): string | null


Test patch:
diff --git a/tests/fixtures/rules/prefer-destructured-store-props/invalid/fixer-test01-errors.yaml b/tests/fixtures/rules/prefer-destructured-store-props/invalid/fixer-test01-errors.yaml
new file mode 100644
index 0000000..bdd5b78
--- /dev/null
+++ b/tests/fixtures/rules/prefer-destructured-store-props/invalid/fixer-test01-errors.yaml
@@ -0,0 +1,108 @@
+- message: Destructure $foo from $store for better change tracking & fewer redraws
+  line: 5
+  column: 8
+  suggestions:
+    - desc: "Using destructuring like $: ({ $foo } = $store); will run faster"
+      messageId: fixUseDestructuring
+      output: |
+        <script>
+          import store from "./store.js"
+        $: ({ $foo: foo } = $store);
+        </script>
+
+        $foo: {foo}
+        bar: {$store.bar}
+        baz: {$store.baz}
+        var: {$store.var}
+        null: {$store.null}
+        undefined: {$store.undefined}
+- message: Destructure bar from $store for better change tracking & fewer redraws
+  line: 6
+  column: 7
+  suggestions:
+    - desc: "Using destructuring like $: ({ bar } = $store); will run faster"
+      messageId: fixUseDestructuring
+      output: |
+        <script>
+          import store from "./store.js"
+        $: ({ bar } = $store);
+        </script>
+
+        $foo: {$store.$foo}
+        bar: {bar}
+        baz: {$store.baz}
+        var: {$store.var}
+        null: {$store.null}
+        undefined: {$store.undefined}
+- message: Destructure baz from $store for better change tracking & fewer redraws
+  line: 7
+  column: 7
+  suggestions:
+    - desc: "Using destructuring like $: ({ baz } = $store); will run faster"
+      messageId: fixUseDestructuring
+      output: |
+        <script>
+          import store from "./store.js"
+        $: ({ baz } = $store);
+        </script>
+
+        $foo: {$store.$foo}
+        bar: {$store.bar}
+        baz: {baz}
+        var: {$store.var}
+        null: {$store.null}
+        undefined: {$store.undefined}
+- message: Destructure var from $store for better change tracking & fewer redraws
+  line: 8
+  column: 7
+  suggestions:
+    - desc: "Using destructuring like $: ({ var } = $store); will run faster"
+      messageId: fixUseDestructuring
+      output: |
+        <script>
+          import store from "./store.js"
+        $: ({ var: var1 } = $store);
+        </script>
+
+        $foo: {$store.$foo}
+        bar: {$store.bar}
+        baz: {$store.baz}
+        var: {var1}
+        null: {$store.null}
+        undefined: {$store.undefined}
+- message: Destructure null from $store for better change tracking & fewer redraws
+  line: 9
+  column: 8
+  suggestions:
+    - desc: "Using destructuring like $: ({ null } = $store); will run faster"
+      messageId: fixUseDestructuring
+      output: |
+        <script>
+          import store from "./store.js"
+        $: ({ null: null1 } = $store);
+        </script>
+
+        $foo: {$store.$foo}
+        bar: {$store.bar}
+        baz: {$store.baz}
+        var: {$store.var}
+        null: {null1}
+        undefined: {$store.undefined}
+- message: Destructure undefined from $store for better change tracking & fewer redraws
+  line: 10
+  column: 13
+  suggestions:
+    - desc: "Using destructuring like $: ({ undefined } = $store); will run faster"
+      messageId: fixUseDestructuring
+      output: |
+        <script>
+          import store from "./store.js"
+        $: ({ undefined: undefined1 } = $store);
+        </script>
+
+        $foo: {$store.$foo}
+        bar: {$store.bar}
+        baz: {$store.baz}
+        var: {$store.var}
+        null: {$store.null}
+        undefined: {undefined1}
diff --git a/tests/fixtures/rules/prefer-destructured-store-props/invalid/fixer-test01-input.svelte b/tests/fixtures/rules/prefer-destructured-store-props/invalid/fixer-test01-input.svelte
new file mode 100644
index 0000000..54e88f7
--- /dev/null
+++ b/tests/fixtures/rules/prefer-destructured-store-props/invalid/fixer-test01-input.svelte
@@ -0,0 +1,10 @@
+<script>
+  import store from "./store.js"
+</script>
+
+$foo: {$store.$foo}
+bar: {$store.bar}
+baz: {$store.baz}
+var: {$store.var}
+null: {$store.null}
+undefined: {$store.undefined}
diff --git a/tests/fixtures/rules/prefer-destructured-store-props/invalid/member01-errors.yaml b/tests/fixtures/rules/prefer-destructured-store-props/invalid/member01-errors.yaml
new file mode 100644
index 0000000..d422c50
--- /dev/null
+++ b/tests/fixtures/rules/prefer-destructured-store-props/invalid/member01-errors.yaml
@@ -0,0 +1,28 @@
+- message: Destructure foo from $store for better change tracking & fewer redraws
+  line: 5
+  column: 11
+  suggestions:
+    - desc: "Using destructuring like $: ({ foo } = $store); will run faster"
+      messageId: fixUseDestructuring
+      output: |
+        <script>
+          import store from "./store.js"
+        $: ({ foo } = $store);
+        </script>
+
+        foo.bar: {foo.bar}
+        foo.baz: {$store.foo.baz}
+- message: Destructure foo from $store for better change tracking & fewer redraws
+  line: 6
+  column: 11
+  suggestions:
+    - desc: "Using destructuring like $: ({ foo } = $store); will run faster"
+      messageId: fixUseDestructuring
+      output: |
+        <script>
+          import store from "./store.js"
+        $: ({ foo } = $store);
+        </script>
+
+        foo.bar: {$store.foo.bar}
+        foo.baz: {foo.baz}
diff --git a/tests/fixtures/rules/prefer-destructured-store-props/invalid/member01-input.svelte b/tests/fixtures/rules/prefer-destructured-store-props/invalid/member01-input.svelte
new file mode 100644
index 0000000..f225e1f
--- /dev/null
+++ b/tests/fixtures/rules/prefer-destructured-store-props/invalid/member01-input.svelte
@@ -0,0 +1,6 @@
+<script>
+  import store from "./store.js"
+</script>
+
+foo.bar: {$store.foo.bar}
+foo.baz: {$store.foo.baz}
diff --git a/tests/fixtures/rules/prefer-destructured-store-props/invalid/member02-errors.yaml b/tests/fixtures/rules/prefer-destructured-store-props/invalid/member02-errors.yaml
new file mode 100644
index 0000000..d5aa9db
--- /dev/null
+++ b/tests/fixtures/rules/prefer-destructured-store-props/invalid/member02-errors.yaml
@@ -0,0 +1,134 @@
+- message: Destructure foo from $store for better change tracking & fewer redraws
+  line: 8
+  column: 11
+  suggestions:
+    - desc: Using the predefined reactive variable foo
+      messageId: fixUseVariable
+      output: |
+        <script>
+          import store from "./store.js"
+          $: ({ foo, bar: b } = $store)
+          $: bbb = $store.baz
+        </script>
+
+        foo.bar: {foo.bar}
+        foo.baz: {foo.baz}
+
+        bar.foo: {b.foo}
+        bar.baz: {$store.bar.baz}
+
+        baz.foo: {bbb.foo}
+        baz.bar: {$store.baz.bar}
+    - desc: "Using destructuring like $: ({ foo } = $store); will run faster"
+      messageId: fixUseDestructuring
+      output: |
+        <script>
+          import store from "./store.js"
+          $: ({ foo, bar: b } = $store)
+          $: bbb = $store.baz
+        $: ({ foo: foo1 } = $store);
+        </script>
+
+        foo.bar: {foo.bar}
+        foo.baz: {foo1.baz}
+
+        bar.foo: {b.foo}
+        bar.baz: {$store.bar.baz}
+
+        baz.foo: {bbb.foo}
+        baz.bar: {$store.baz.bar}
+- message: Destructure bar from $store for better change tracking & fewer redraws
+  line: 11
+  column: 11
+  suggestions:
+    - desc: Using the predefined reactive variable b
+      messageId: fixUseVariable
+      output: |
+        <script>
+          import store from "./store.js"
+          $: ({ foo, bar: b } = $store)
+          $: bbb = $store.baz
+        </script>
+
+        foo.bar: {foo.bar}
+        foo.baz: {$store.foo.baz}
+
+        bar.foo: {b.foo}
+        bar.baz: {b.baz}
+
+        baz.foo: {bbb.foo}
+        baz.bar: {$store.baz.bar}
+    - desc: "Using destructuring like $: ({ bar } = $store); will run faster"
+      messageId: fixUseDestructuring
+      output: |
+        <script>
+          import store from "./store.js"
+          $: ({ foo, bar: b } = $store)
+          $: bbb = $store.baz
+        $: ({ bar } = $store);
+        </script>
+
+        foo.bar: {foo.bar}
+        foo.baz: {$store.foo.baz}
+
+        bar.foo: {b.foo}
+        bar.baz: {bar.baz}
+
+        baz.foo: {bbb.foo}
+        baz.bar: {$store.baz.bar}
+- message: Destructure baz from $store for better change tracking & fewer redraws
+  line: 14
+  column: 11
+  suggestions:
+    - desc: Using the predefined reactive variable bbb
+      messageId: fixUseVariable
+      output: |
+        <script>
+          import store from "./store.js"
+          $: ({ foo, bar: b } = $store)
+          $: bbb = $store.baz
+        </script>
+
+        foo.bar: {foo.bar}
+        foo.baz: {$store.foo.baz}
+
+        bar.foo: {b.foo}
+        bar.baz: {$store.bar.baz}
+
+        baz.foo: {bbb.foo}
+        baz.bar: {bbb.bar}
+    - desc: Using the predefined reactive variable bbb
+      messageId: fixUseVariable
+      output: |
+        <script>
+          import store from "./store.js"
+          $: ({ foo, bar: b } = $store)
+          $: bbb = $store.baz
+        </script>
+
+        foo.bar: {foo.bar}
+        foo.baz: {$store.foo.baz}
+
+        bar.foo: {b.foo}
+        bar.baz: {$store.bar.baz}
+
+        baz.foo: {bbb.foo}
+        baz.bar: {bbb.bar}
+    - desc: "Using destructuring like $: ({ baz } = $store); will run faster"
+      messageId: fixUseDestructuring
+      output: |
+        <script>
+          import store from "./store.js"
+          $: ({ foo, bar: b } = $store)
+          $: bbb = $store.baz
+        $: ({ baz } = $store);
+        </script>
+
+        foo.bar: {foo.bar}
+        foo.baz: {$store.foo.baz}
+
+        bar.foo: {b.foo}
+        bar.baz: {$store.bar.baz}
+
+        baz.foo: {bbb.foo}
+        baz.bar: {baz.bar}
diff --git a/tests/fixtures/rules/prefer-destructured-store-props/invalid/member02-input.svelte b/tests/fixtures/rules/prefer-destructured-store-props/invalid/member02-input.svelte
new file mode 100644
index 0000000..7eea0da
--- /dev/null
+++ b/tests/fixtures/rules/prefer-destructured-store-props/invalid/member02-input.svelte
@@ -0,0 +1,14 @@
+<script>
+  import store from "./store.js"
+  $: ({ foo, bar: b } = $store)
+  $: bbb = $store.baz
+</script>
+
+foo.bar: {foo.bar}
+foo.baz: {$store.foo.baz}
+
+bar.foo: {b.foo}
+bar.baz: {$store.bar.baz}
+
+baz.foo: {bbb.foo}
+baz.bar: {$store.baz.bar}
diff --git a/tests/fixtures/rules/prefer-destructured-store-props/invalid/member03-errors.yaml b/tests/fixtures/rules/prefer-destructured-store-props/invalid/member03-errors.yaml
new file mode 100644
index 0000000..0328980
--- /dev/null
+++ b/tests/fixtures/rules/prefer-destructured-store-props/invalid/member03-errors.yaml
@@ -0,0 +1,69 @@
+- message: Destructure foo from $store for better change tracking & fewer redraws
+  line: 9
+  column: 11
+  suggestions:
+    - desc: "Using destructuring like $: ({ foo } = $store); will run faster"
+      messageId: fixUseDestructuring
+      output: |
+        <script>
+          import store from "./store.js"
+          // incorrect
+          $: ({ foo, bar: b } = store)
+          $: bbb = store.baz
+        $: ({ foo: foo1 } = $store);
+        </script>
+
+        foo.bar: {foo.bar}
+        foo.baz: {foo1.baz}
+
+        bar.foo: {b.foo}
+        bar.baz: {$store.bar.baz}
+
+        baz.foo: {bbb.foo}
+        baz.bar: {$store.baz.bar}
+- message: Destructure bar from $store for better change tracking & fewer redraws
+  line: 12
+  column: 11
+  suggestions:
+    - desc: "Using destructuring like $: ({ bar } = $store); will run faster"
+      messageId: fixUseDestructuring
+      output: |
+        <script>
+          import store from "./store.js"
+          // incorrect
+          $: ({ foo, bar: b } = store)
+          $: bbb = store.baz
+        $: ({ bar } = $store);
+        </script>
+
+        foo.bar: {foo.bar}
+        foo.baz: {$store.foo.baz}
+
+        bar.foo: {b.foo}
+        bar.baz: {bar.baz}
+
+        baz.foo: {bbb.foo}
+        baz.bar: {$store.baz.bar}
+- message: Destructure baz from $store for better change tracking & fewer redraws
+  line: 15
+  column: 11
+  suggestions:
+    - desc: "Using destructuring like $: ({ baz } = $store); will run faster"
+      messageId: fixUseDestructuring
+      output: |
+        <script>
+          import store from "./store.js"
+          // incorrect
+          $: ({ foo, bar: b } = store)
+          $: bbb = store.baz
+        $: ({ baz } = $store);
+        </script>
+
+        foo.bar: {foo.bar}
+        foo.baz: {$store.foo.baz}
+
+        bar.foo: {b.foo}
+        bar.baz: {$store.bar.baz}
+
+        baz.foo: {bbb.foo}
+        baz.bar: {baz.bar}
diff --git a/tests/fixtures/rules/prefer-destructured-store-props/invalid/member03-input.svelte b/tests/fixtures/rules/prefer-destructured-store-props/invalid/member03-input.svelte
new file mode 100644
index 0000000..f821d79
--- /dev/null
+++ b/tests/fixtures/rules/prefer-destructured-store-props/invalid/member03-input.svelte
@@ -0,0 +1,15 @@
+<script>
+  import store from "./store.js"
+  // incorrect
+  $: ({ foo, bar: b } = store)
+  $: bbb = store.baz
+</script>
+
+foo.bar: {foo.bar}
+foo.baz: {$store.foo.baz}
+
+bar.foo: {b.foo}
+bar.baz: {$store.bar.baz}
+
+baz.foo: {bbb.foo}
+baz.bar: {$store.baz.bar}
diff --git a/tests/fixtures/rules/prefer-destructured-store-props/invalid/module-errors.yaml b/tests/fixtures/rules/prefer-destructured-store-props/invalid/module-errors.yaml
new file mode 100644
index 0000000..857b90f
--- /dev/null
+++ b/tests/fixtures/rules/prefer-destructured-store-props/invalid/module-errors.yaml
@@ -0,0 +1,4 @@
+- message: Destructure bar from $foo for better change tracking & fewer redraws
+  line: 4
+  column: 2
+  suggestions: null
diff --git a/tests/fixtures/rules/prefer-destructured-store-props/invalid/module-input.svelte b/tests/fixtures/rules/prefer-destructured-store-props/invalid/module-input.svelte
new file mode 100644
index 0000000..0f6c8ca
--- /dev/null
+++ b/tests/fixtures/rules/prefer-destructured-store-props/invalid/module-input.svelte
@@ -0,0 +1,4 @@
+<script context="module">
+</script>
+
+{$foo.bar}
diff --git a/tests/fixtures/rules/prefer-destructured-store-props/invalid/test01-errors.yaml b/tests/fixtures/rules/prefer-destructured-store-props/invalid/test01-errors.yaml
new file mode 100644
index 0000000..fb70370
--- /dev/null
+++ b/tests/fixtures/rules/prefer-destructured-store-props/invalid/test01-errors.yaml
@@ -0,0 +1,25 @@
+- message: Destructure bar from $foo for better change tracking & fewer redraws
+  line: 4
+  column: 2
+  suggestions:
+    - desc: "Using destructuring like $: ({ bar } = $foo); will run faster"
+      messageId: fixUseDestructuring
+      output: |
+        <script>
+        $: ({ bar } = $foo);
+        </script>
+
+        {bar}
+
+        {$foo[baz]}
+
+        <!-- eslint-disable-next-line dot-notation -- tests -->
+        {$foo["qux"]}
+- message: Destructure baz from $foo for better change tracking & fewer redraws
+  line: 6
+  column: 2
+  suggestions: null
+- message: Destructure "qux" from $foo for better change tracking & fewer redraws
+  line: 9
+  column: 2
+  suggestions: null
diff --git a/tests/fixtures/rules/prefer-destructured-store-props/invalid/test01-input.svelte b/tests/fixtures/rules/prefer-destructured-store-props/invalid/test01-input.svelte
new file mode 100644
index 0000000..daaab05
--- /dev/null
+++ b/tests/fixtures/rules/prefer-destructured-store-props/invalid/test01-input.svelte
@@ -0,0 +1,9 @@
+<script>
+</script>
+
+{$foo.bar}
+
+{$foo[baz]}
+
+<!-- eslint-disable-next-line dot-notation -- tests -->
+{$foo["qux"]}
diff --git a/tests/fixtures/rules/prefer-destructured-store-props/invalid/test02-errors.yaml b/tests/fixtures/rules/prefer-destructured-store-props/invalid/test02-errors.yaml
new file mode 100644
index 0000000..40d1bdc
--- /dev/null
+++ b/tests/fixtures/rules/prefer-destructured-store-props/invalid/test02-errors.yaml
@@ -0,0 +1,12 @@
+- message: Destructure bar from $foo for better change tracking & fewer redraws
+  line: 1
+  column: 2
+  suggestions: null
+- message: Destructure bar from $foo for better change tracking & fewer redraws
+  line: 3
+  column: 2
+  suggestions: null
+- message: Destructure "bar" from $foo for better change tracking & fewer redraws
+  line: 6
+  column: 2
+  suggestions: null
diff --git a/tests/fixtures/rules/prefer-destructured-store-props/invalid/test02-input.svelte b/tests/fixtures/rules/prefer-destructured-store-props/invalid/test02-input.svelte
new file mode 100644
index 0000000..f824087
--- /dev/null
+++ b/tests/fixtures/rules/prefer-destructured-store-props/invalid/test02-input.svelte
@@ -0,0 +1,6 @@
+{$foo.bar}
+
+{$foo[bar]}
+
+<!-- eslint-disable-next-line dot-notation -- tests -->
+{$foo["bar"]}
diff --git a/tests/fixtures/rules/prefer-destructured-store-props/invalid/test03-errors.yaml b/tests/fixtures/rules/prefer-destructured-store-props/invalid/test03-errors.yaml
new file mode 100644
index 0000000..5e40736
--- /dev/null
+++ b/tests/fixtures/rules/prefer-destructured-store-props/invalid/test03-errors.yaml
@@ -0,0 +1,27 @@
+- message: Destructure baz from $store for better change tracking & fewer redraws
+  line: 18
+  column: 9
+  suggestions:
+    - desc: "Using destructuring like $: ({ baz } = $store); will run faster"
+      messageId: fixUseDestructuring
+      output: |
+        <script>
+          import store from "./store.js"
+
+          let foo, bar
+          $: {
+            foo = $store.foo
+            bar = $store.bar
+          }
+        $: ({ baz } = $store);
+        </script>
+
+        <div>
+          foo: {`${foo} ${Date.now()}`}
+        </div>
+        <div>
+          bar: {`${bar} ${Date.now()}`}
+        </div>
+        <div>
+          baz: {baz}
+        </div>
diff --git a/tests/fixtures/rules/prefer-destructured-store-props/invalid/test03-input.svelte b/tests/fixtures/rules/prefer-destructured-store-props/invalid/test03-input.svelte
new file mode 100644
index 0000000..f5896ce
--- /dev/null
+++ b/tests/fixtures/rules/prefer-destructured-store-props/invalid/test03-input.svelte
@@ -0,0 +1,19 @@
+<script>
+  import store from "./store.js"
+
+  let foo, bar
+  $: {
+    foo = $store.foo
+    bar = $store.bar
+  }
+</script>
+
+<div>
+  foo: {`${foo} ${Date.now()}`}
+</div>
+<div>
+  bar: {`${bar} ${Date.now()}`}
+</div>
+<div>
+  baz: {$store.baz}
+</div>
diff --git a/tests/fixtures/rules/prefer-destructured-store-props/valid/script-test01-input.svelte b/tests/fixtures/rules/prefer-destructured-store-props/valid/script-test01-input.svelte
new file mode 100644
index 0000000..b345e68
--- /dev/null
+++ b/tests/fixtures/rules/prefer-destructured-store-props/valid/script-test01-input.svelte
@@ -0,0 +1,6 @@
+<script>
+  import store from "./store.js"
+
+  $: foo = $store.foo
+  $: ({ bar } = $store)
+</script>
diff --git a/tests/fixtures/rules/prefer-destructured-store-props/valid/test01-input.svelte b/tests/fixtures/rules/prefer-destructured-store-props/valid/test01-input.svelte
new file mode 100644
index 0000000..84010d9
--- /dev/null
+++ b/tests/fixtures/rules/prefer-destructured-store-props/valid/test01-input.svelte
@@ -0,0 +1,6 @@
+{foo$.bar}
+{f$oo.bar}
+
+{#each list as baz}
+  {$foo[`bar${baz}`]}
+{/each}
diff --git a/tests/src/rules/prefer-destructured-store-props.ts b/tests/src/rules/prefer-destructured-store-props.ts
new file mode 100644
index 0000000..7cdbf8e
--- /dev/null
+++ b/tests/src/rules/prefer-destructured-store-props.ts
@@ -0,0 +1,16 @@
+import { RuleTester } from "eslint"
+import rule from "../../../src/rules/prefer-destructured-store-props"
+import { loadTestCases } from "../../utils/utils"
+
+const tester = new RuleTester({
+  parserOptions: {
+    ecmaVersion: 2020,
+    sourceType: "module",
+  },
+})
+
+tester.run(
+  "prefer-destructured-store-props",
+  rule as any,
+  loadTestCases("prefer-destructured-store-props"),
+)