diff --git a/.changeset/small-months-exercise.md b/.changeset/small-months-exercise.md
new file mode 100644
index 000000000..1f017385d
--- /dev/null
+++ b/.changeset/small-months-exercise.md
@@ -0,0 +1,5 @@
+---
+'eslint-plugin-svelte': major
+---
+
+Adds `prefer-let` rule
diff --git a/README.md b/README.md
index 35c16c8e8..d55422f97 100644
--- a/README.md
+++ b/README.md
@@ -370,6 +370,7 @@ These rules relate to better ways of doing things to help you avoid problems:
| [svelte/no-useless-mustaches](https://sveltejs.github.io/eslint-plugin-svelte/rules/no-useless-mustaches/) | disallow unnecessary mustache interpolations | :wrench: |
| [svelte/prefer-const](https://sveltejs.github.io/eslint-plugin-svelte/rules/prefer-const/) | Require `const` declarations for variables that are never reassigned after declared | :wrench: |
| [svelte/prefer-destructured-store-props](https://sveltejs.github.io/eslint-plugin-svelte/rules/prefer-destructured-store-props/) | destructure values from object stores for better change tracking & fewer redraws | :bulb: |
+| [svelte/prefer-let](https://sveltejs.github.io/eslint-plugin-svelte/rules/prefer-let/) | Prefer `let` over `const` for Svelte 5 reactive variable declarations. | :wrench: |
| [svelte/require-each-key](https://sveltejs.github.io/eslint-plugin-svelte/rules/require-each-key/) | require keyed `{#each}` block | |
| [svelte/require-event-dispatcher-types](https://sveltejs.github.io/eslint-plugin-svelte/rules/require-event-dispatcher-types/) | require type parameters for `createEventDispatcher` | |
| [svelte/require-optimized-style-attribute](https://sveltejs.github.io/eslint-plugin-svelte/rules/require-optimized-style-attribute/) | require style attributes that can be optimized | |
diff --git a/docs/rules.md b/docs/rules.md
index 047b2ab29..b6dfa93df 100644
--- a/docs/rules.md
+++ b/docs/rules.md
@@ -67,6 +67,7 @@ These rules relate to better ways of doing things to help you avoid problems:
| [svelte/no-useless-mustaches](./rules/no-useless-mustaches.md) | disallow unnecessary mustache interpolations | :wrench: |
| [svelte/prefer-const](./rules/prefer-const.md) | Require `const` declarations for variables that are never reassigned after declared | :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/prefer-let](./rules/prefer-let.md) | Prefer `let` over `const` for Svelte 5 reactive variable declarations. | :wrench: |
| [svelte/require-each-key](./rules/require-each-key.md) | require keyed `{#each}` block | |
| [svelte/require-event-dispatcher-types](./rules/require-event-dispatcher-types.md) | require type parameters for `createEventDispatcher` | |
| [svelte/require-optimized-style-attribute](./rules/require-optimized-style-attribute.md) | require style attributes that can be optimized | |
diff --git a/docs/rules/prefer-let.md b/docs/rules/prefer-let.md
new file mode 100644
index 000000000..d56ec034b
--- /dev/null
+++ b/docs/rules/prefer-let.md
@@ -0,0 +1,58 @@
+---
+pageClass: 'rule-details'
+sidebarDepth: 0
+title: 'svelte/prefer-let'
+description: 'Prefer `let` over `const` for Svelte 5 reactive variable declarations.'
+---
+
+# svelte/prefer-let
+
+> Prefer `let` over `const` for Svelte 5 reactive variable declarations.
+
+- :exclamation: **_This rule has not been released yet._**
+- :wrench: The `--fix` option on the [command line](https://eslint.org/docs/user-guide/command-line-interface#fixing-problems) can automatically fix some of the problems reported by this rule.
+
+## :book: Rule Details
+
+This rule reports usages of `const` variable declarations on Svelte reactive
+function assignments. While values may not be reassigned in the code itself,
+they are reassigned by Svelte.
+
+
+
+```svelte
+
+```
+
+## :wrench: Options
+
+```json
+{
+ "svelte/prefer-const": [
+ "error",
+ {
+ "exclude": ["$props", "$derived", "$derived.by", "$state", "$state.raw"]
+ }
+ ]
+}
+```
+
+- `exclude`: The reactive assignments that you want to exclude from being
+ reported.
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/sveltejs/eslint-plugin-svelte/blob/main/packages/eslint-plugin-svelte/src/rules/prefer-let.ts)
+- [Test source](https://github.com/sveltejs/eslint-plugin-svelte/blob/main/packages/eslint-plugin-svelte/tests/src/rules/prefer-let.ts)
diff --git a/packages/eslint-plugin-svelte/src/rule-types.ts b/packages/eslint-plugin-svelte/src/rule-types.ts
index b07451a1b..8904adb8e 100644
--- a/packages/eslint-plugin-svelte/src/rule-types.ts
+++ b/packages/eslint-plugin-svelte/src/rule-types.ts
@@ -285,6 +285,11 @@ export interface RuleOptions {
* @see https://sveltejs.github.io/eslint-plugin-svelte/rules/prefer-destructured-store-props/
*/
'svelte/prefer-destructured-store-props'?: Linter.RuleEntry<[]>
+ /**
+ * Prefer `let` over `const` for Svelte 5 reactive variable declarations.
+ * @see https://sveltejs.github.io/eslint-plugin-svelte/rules/prefer-let/
+ */
+ 'svelte/prefer-let'?: Linter.RuleEntry
/**
* require style directives instead of style attribute
* @see https://sveltejs.github.io/eslint-plugin-svelte/rules/prefer-style-directive/
@@ -513,6 +518,10 @@ type SveltePreferConst = []|[{
destructuring?: ("any" | "all")
ignoreReadBeforeAssign?: boolean
}]
+// ----- svelte/prefer-let -----
+type SveltePreferLet = []|[{
+ exclude?: ("$props" | "$derived" | "$derived.by" | "$state" | "$state.raw")[]
+}]
// ----- svelte/shorthand-attribute -----
type SvelteShorthandAttribute = []|[{
prefer?: ("always" | "never")
diff --git a/packages/eslint-plugin-svelte/src/rules/prefer-let.ts b/packages/eslint-plugin-svelte/src/rules/prefer-let.ts
new file mode 100644
index 000000000..929b3c1ca
--- /dev/null
+++ b/packages/eslint-plugin-svelte/src/rules/prefer-let.ts
@@ -0,0 +1,90 @@
+import type { TSESTree } from '@typescript-eslint/types';
+
+import { createRule } from '../utils/index.js';
+
+type ReactiveFunction = '$props' | '$derived' | '$derived.by' | '$state' | '$state.raw';
+const DEFAULT_FUNCTIONS: ReactiveFunction[] = [
+ '$props',
+ '$derived',
+ '$derived.by',
+ '$state',
+ '$state.raw'
+];
+
+function getReactiveFunction(callExpr: TSESTree.CallExpression, validNames: string[]) {
+ if (callExpr.callee.type === 'Identifier') {
+ if (validNames.includes(callExpr.callee.name)) {
+ return callExpr.callee.name as ReactiveFunction;
+ }
+ } else if (
+ callExpr.callee.type === 'MemberExpression' &&
+ callExpr.callee.object.type === 'Identifier' &&
+ callExpr.callee.property.type === 'Identifier'
+ ) {
+ const fullName = `${callExpr.callee.object.name}.${callExpr.callee.property.name}`;
+
+ if (validNames.includes(fullName)) {
+ return fullName as ReactiveFunction;
+ }
+ }
+
+ return null;
+}
+
+export default createRule('prefer-let', {
+ meta: {
+ docs: {
+ description: 'Prefer `let` over `const` for Svelte 5 reactive variable declarations.',
+ category: 'Best Practices',
+ recommended: false
+ },
+ schema: [
+ {
+ type: 'object',
+ properties: {
+ exclude: {
+ type: 'array',
+ items: {
+ enum: ['$props', '$derived', '$derived.by', '$state', '$state.raw']
+ },
+ uniqueItems: true
+ }
+ },
+ additionalProperties: false
+ }
+ ],
+ messages: {
+ 'use-let': "'const' is used for a reactive declaration from {{rune}}. Use 'let' instead."
+ },
+ type: 'suggestion',
+ fixable: 'code'
+ },
+ create(context) {
+ const exclude = context.options[0]?.exclude ?? [];
+ const allowedNames = DEFAULT_FUNCTIONS.filter((name) => !exclude.includes(name));
+
+ return {
+ VariableDeclaration(node: TSESTree.VariableDeclaration) {
+ if (node.kind === 'const') {
+ node.declarations.forEach((declarator) => {
+ const init = declarator.init;
+
+ if (!init || init.type !== 'CallExpression') {
+ return;
+ }
+
+ const rune = getReactiveFunction(init, allowedNames);
+ if (rune) {
+ context.report({
+ node,
+ messageId: 'use-let',
+ data: { rune },
+ fix: (fixer) => fixer.replaceTextRange([node.range[0], node.range[0] + 5], 'let')
+ });
+ }
+ });
+ }
+ }
+ };
+ }
+});
diff --git a/packages/eslint-plugin-svelte/src/utils/rules.ts b/packages/eslint-plugin-svelte/src/utils/rules.ts
index cb0cbe896..bb32675f8 100644
--- a/packages/eslint-plugin-svelte/src/utils/rules.ts
+++ b/packages/eslint-plugin-svelte/src/utils/rules.ts
@@ -56,6 +56,7 @@ import noUselessMustaches from '../rules/no-useless-mustaches.js';
import preferClassDirective from '../rules/prefer-class-directive.js';
import preferConst from '../rules/prefer-const.js';
import preferDestructuredStoreProps from '../rules/prefer-destructured-store-props.js';
+import preferLet from '../rules/prefer-let.js';
import preferStyleDirective from '../rules/prefer-style-directive.js';
import requireEachKey from '../rules/require-each-key.js';
import requireEventDispatcherTypes from '../rules/require-event-dispatcher-types.js';
@@ -127,6 +128,7 @@ export const rules = [
preferClassDirective,
preferConst,
preferDestructuredStoreProps,
+ preferLet,
preferStyleDirective,
requireEachKey,
requireEventDispatcherTypes,
diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/invalid/exclude/_config.json b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/invalid/exclude/_config.json
new file mode 100644
index 000000000..60db6d6ea
--- /dev/null
+++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/invalid/exclude/_config.json
@@ -0,0 +1 @@
+{ "options": [{ "exclude": ["$derived", "$derived.by"] }] }
diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/invalid/exclude/exclude-errors.yaml b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/invalid/exclude/exclude-errors.yaml
new file mode 100644
index 000000000..2f5b37747
--- /dev/null
+++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/invalid/exclude/exclude-errors.yaml
@@ -0,0 +1,4 @@
+- message: "'const' is used for a reactive declaration from $state. Use 'let' instead."
+ line: 2
+ column: 2
+ suggestions: null
diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/invalid/exclude/exclude-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/invalid/exclude/exclude-input.svelte
new file mode 100644
index 000000000..602a48115
--- /dev/null
+++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/invalid/exclude/exclude-input.svelte
@@ -0,0 +1,5 @@
+
diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/invalid/exclude/exclude-output.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/invalid/exclude/exclude-output.svelte
new file mode 100644
index 000000000..52b619e99
--- /dev/null
+++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/invalid/exclude/exclude-output.svelte
@@ -0,0 +1,5 @@
+
diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/invalid/test01-errors.yaml b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/invalid/test01-errors.yaml
new file mode 100644
index 000000000..d4a3b8a5d
--- /dev/null
+++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/invalid/test01-errors.yaml
@@ -0,0 +1,23 @@
+- message: "'const' is used for a reactive declaration from $props. Use 'let' instead."
+ line: 2
+ column: 2
+ suggestions: null
+- message: "'const' is used for a reactive declaration from $state. Use 'let' instead."
+ line: 3
+ column: 2
+ suggestions: null
+- message: "'const' is used for a reactive declaration from $state.raw. Use 'let'
+ instead."
+ line: 4
+ column: 2
+ suggestions: null
+- message: "'const' is used for a reactive declaration from $derived. Use 'let'
+ instead."
+ line: 5
+ column: 2
+ suggestions: null
+- message: "'const' is used for a reactive declaration from $derived.by. Use 'let'
+ instead."
+ line: 6
+ column: 2
+ suggestions: null
diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/invalid/test01-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/invalid/test01-input.svelte
new file mode 100644
index 000000000..0984af843
--- /dev/null
+++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/invalid/test01-input.svelte
@@ -0,0 +1,7 @@
+
diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/invalid/test01-output.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/invalid/test01-output.svelte
new file mode 100644
index 000000000..ab587a28f
--- /dev/null
+++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/invalid/test01-output.svelte
@@ -0,0 +1,7 @@
+
diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/valid/test01-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/valid/test01-input.svelte
new file mode 100644
index 000000000..ab587a28f
--- /dev/null
+++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-let/valid/test01-input.svelte
@@ -0,0 +1,7 @@
+
diff --git a/packages/eslint-plugin-svelte/tests/src/rules/prefer-const.ts b/packages/eslint-plugin-svelte/tests/src/rules/prefer-const.ts
index ffe176d0a..2dd42eeaa 100644
--- a/packages/eslint-plugin-svelte/tests/src/rules/prefer-const.ts
+++ b/packages/eslint-plugin-svelte/tests/src/rules/prefer-const.ts
@@ -1,6 +1,6 @@
-import { RuleTester } from '../../utils/eslint-compat';
-import rule from '../../../src/rules/prefer-const';
-import { loadTestCases } from '../../utils/utils';
+import { RuleTester } from '../../utils/eslint-compat.js';
+import rule from '../../../src/rules/prefer-const.js';
+import { loadTestCases } from '../../utils/utils.js';
const tester = new RuleTester({
languageOptions: {
diff --git a/packages/eslint-plugin-svelte/tests/src/rules/prefer-let.ts b/packages/eslint-plugin-svelte/tests/src/rules/prefer-let.ts
new file mode 100644
index 000000000..292762556
--- /dev/null
+++ b/packages/eslint-plugin-svelte/tests/src/rules/prefer-let.ts
@@ -0,0 +1,12 @@
+import { RuleTester } from '../../utils/eslint-compat.js';
+import rule from '../../../src/rules/prefer-let.js';
+import { loadTestCases } from '../../utils/utils.js';
+
+const tester = new RuleTester({
+ languageOptions: {
+ ecmaVersion: 2020,
+ sourceType: 'module'
+ },
+});
+
+tester.run('prefer-let', rule as any, loadTestCases('prefer-let'));