diff --git a/.changeset/ready-views-burn.md b/.changeset/ready-views-burn.md new file mode 100644 index 000000000..a5f11d84b --- /dev/null +++ b/.changeset/ready-views-burn.md @@ -0,0 +1,5 @@ +--- +'eslint-plugin-svelte': minor +--- + +feat: add `prefer-writable-derived` rule diff --git a/README.md b/README.md index c379a3d82..f9c7bfee2 100644 --- a/README.md +++ b/README.md @@ -310,6 +310,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 | :star::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-writable-derived](https://sveltejs.github.io/eslint-plugin-svelte/rules/prefer-writable-derived/) | Prefer using writable $derived instead of $state and $effect | :star::bulb: | | [svelte/require-each-key](https://sveltejs.github.io/eslint-plugin-svelte/rules/require-each-key/) | require keyed `{#each}` block | :star: | | [svelte/require-event-dispatcher-types](https://sveltejs.github.io/eslint-plugin-svelte/rules/require-event-dispatcher-types/) | require type parameters for `createEventDispatcher` | :star: | | [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 df2ca5ec4..7433dee57 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 | :star::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-writable-derived](./rules/prefer-writable-derived.md) | Prefer using writable $derived instead of $state and $effect | :star::bulb: | | [svelte/require-each-key](./rules/require-each-key.md) | require keyed `{#each}` block | :star: | | [svelte/require-event-dispatcher-types](./rules/require-event-dispatcher-types.md) | require type parameters for `createEventDispatcher` | :star: | | [svelte/require-optimized-style-attribute](./rules/require-optimized-style-attribute.md) | require style attributes that can be optimized | | diff --git a/docs/rules/prefer-writable-derived.md b/docs/rules/prefer-writable-derived.md new file mode 100644 index 000000000..830bec2aa --- /dev/null +++ b/docs/rules/prefer-writable-derived.md @@ -0,0 +1,60 @@ +--- +pageClass: 'rule-details' +sidebarDepth: 0 +title: 'svelte/prefer-writable-derived' +description: 'Prefer using writable $derived instead of $state and $effect' +--- + +# svelte/prefer-writable-derived + +> Prefer using writable $derived instead of $state and $effect + +- :exclamation: **_This rule has not been released yet._** +- :gear: This rule is included in `"plugin:svelte/recommended"`. +- :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 when you use a combination of `$state` and `$effect` to create a derived value that can be written to. It encourages using the more concise and clearer `$derived` syntax instead. + + + +```svelte + +``` + +The rule specifically looks for patterns where: + +1. You initialize a variable with `$state()` +2. You then use `$effect()` or `$effect.pre()` to assign a new value to that same variable +3. The effect function contains only a single assignment statement + +When this pattern is detected, the rule suggests refactoring to use `$derived()` instead, which provides the same functionality in a more concise way. + +## :wrench: Options + +Nothing. + +- This rule has no options. + +## :books: Further Reading + +- [Svelte Documentation on Reactivity Primitives](https://svelte.dev/docs/svelte-components#script-2-assignments-are-reactive) +- [Svelte RFC for Reactivity Primitives](https://github.com/sveltejs/rfcs/blob/rfc-better-primitives/text/0000-better-primitives.md) + +## :mag: Implementation + +- [Rule source](https://github.com/sveltejs/eslint-plugin-svelte/blob/main/packages/eslint-plugin-svelte/src/rules/prefer-writable-derived.ts) +- [Test source](https://github.com/sveltejs/eslint-plugin-svelte/blob/main/packages/eslint-plugin-svelte/tests/src/rules/prefer-writable-derived.ts) diff --git a/packages/eslint-plugin-svelte/src/configs/flat/recommended.ts b/packages/eslint-plugin-svelte/src/configs/flat/recommended.ts index b1b2d51ad..7a08befc8 100644 --- a/packages/eslint-plugin-svelte/src/configs/flat/recommended.ts +++ b/packages/eslint-plugin-svelte/src/configs/flat/recommended.ts @@ -37,6 +37,7 @@ const config: Linter.Config[] = [ 'svelte/no-unused-svelte-ignore': 'error', 'svelte/no-useless-children-snippet': 'error', 'svelte/no-useless-mustaches': 'error', + 'svelte/prefer-writable-derived': 'error', 'svelte/require-each-key': 'error', 'svelte/require-event-dispatcher-types': 'error', 'svelte/require-store-reactive-access': 'error', diff --git a/packages/eslint-plugin-svelte/src/rule-types.ts b/packages/eslint-plugin-svelte/src/rule-types.ts index 19e30a345..5da4f15a4 100644 --- a/packages/eslint-plugin-svelte/src/rule-types.ts +++ b/packages/eslint-plugin-svelte/src/rule-types.ts @@ -306,6 +306,11 @@ export interface RuleOptions { * @see https://sveltejs.github.io/eslint-plugin-svelte/rules/prefer-style-directive/ */ 'svelte/prefer-style-directive'?: Linter.RuleEntry<[]> + /** + * Prefer using writable $derived instead of $state and $effect + * @see https://sveltejs.github.io/eslint-plugin-svelte/rules/prefer-writable-derived/ + */ + 'svelte/prefer-writable-derived'?: Linter.RuleEntry<[]> /** * require keyed `{#each}` block * @see https://sveltejs.github.io/eslint-plugin-svelte/rules/require-each-key/ diff --git a/packages/eslint-plugin-svelte/src/rules/prefer-writable-derived.ts b/packages/eslint-plugin-svelte/src/rules/prefer-writable-derived.ts new file mode 100644 index 000000000..c7f859dbd --- /dev/null +++ b/packages/eslint-plugin-svelte/src/rules/prefer-writable-derived.ts @@ -0,0 +1,147 @@ +import type { TSESTree } from '@typescript-eslint/types'; +import { createRule } from '../utils/index.js'; +import { getScope } from '../utils/ast-utils.js'; +import { VERSION as SVELTE_VERSION } from 'svelte/compiler'; +import semver from 'semver'; + +// Writable derived were introduced in Svelte 5.25.0 +const shouldRun = semver.satisfies(SVELTE_VERSION, '>=5.25.0'); + +type ValidFunctionType = TSESTree.FunctionExpression | TSESTree.ArrowFunctionExpression; +type ValidFunction = ValidFunctionType & { + body: TSESTree.BlockStatement; +}; + +type ValidAssignmentExpression = TSESTree.AssignmentExpression & { + operator: '='; + left: TSESTree.Identifier; +}; + +type ValidExpressionStatement = TSESTree.ExpressionStatement & { + expression: ValidAssignmentExpression; +}; + +function isEffectOrEffectPre(node: TSESTree.CallExpression) { + if (node.callee.type === 'Identifier') { + return node.callee.name === '$effect'; + } + if (node.callee.type === 'MemberExpression') { + return ( + node.callee.object.type === 'Identifier' && + node.callee.object.name === '$effect' && + node.callee.property.type === 'Identifier' && + node.callee.property.name === 'pre' + ); + } + + return false; +} + +function isValidFunctionArgument(argument: TSESTree.Node): argument is ValidFunction { + if ( + (argument.type !== 'FunctionExpression' && argument.type !== 'ArrowFunctionExpression') || + argument.params.length !== 0 + ) { + return false; + } + + if (argument.body.type !== 'BlockStatement') { + return false; + } + + return argument.body.body.length === 1; +} + +function isValidAssignment(statement: TSESTree.Statement): statement is ValidExpressionStatement { + if (statement.type !== 'ExpressionStatement') return false; + + const { expression } = statement; + return ( + expression.type === 'AssignmentExpression' && + expression.operator === '=' && + expression.left.type === 'Identifier' + ); +} + +function isStateVariable(init: TSESTree.Expression | null): init is TSESTree.CallExpression { + return ( + init?.type === 'CallExpression' && + init.callee.type === 'Identifier' && + init.callee.name === '$state' + ); +} + +export default createRule('prefer-writable-derived', { + meta: { + docs: { + description: 'Prefer using writable $derived instead of $state and $effect', + category: 'Best Practices', + recommended: true + }, + schema: [], + messages: { + unexpected: 'Prefer using writable $derived instead of $state and $effect', + suggestRewrite: 'Rewrite $state and $effect to $derived' + }, + type: 'suggestion', + conditions: [ + { + svelteVersions: ['5'], + runes: [true, 'undetermined'] + } + ], + hasSuggestions: true + }, + create(context) { + if (!shouldRun) { + return {}; + } + return { + CallExpression: (node: TSESTree.CallExpression) => { + if (!isEffectOrEffectPre(node) || node.arguments.length !== 1) { + return; + } + + const argument = node.arguments[0]; + if (!isValidFunctionArgument(argument)) { + return; + } + + const statement = argument.body.body[0]; + if (!isValidAssignment(statement)) { + return; + } + + const { left, right } = statement.expression; + const scope = getScope(context, statement); + const reference = scope.references.find( + (ref) => ref.identifier.type === 'Identifier' && ref.identifier.name === left.name + ); + + const def = reference?.resolved?.defs?.[0]; + if (!def || def.type !== 'Variable' || def.node.type !== 'VariableDeclarator') { + return; + } + + const { init } = def.node; + if (!isStateVariable(init)) { + return; + } + + context.report({ + node: def.node, + messageId: 'unexpected', + suggest: [ + { + messageId: 'suggestRewrite', + fix: (fixer) => { + const rightCode = context.sourceCode.getText(right); + return [fixer.replaceText(init, `$derived(${rightCode})`), fixer.remove(node)]; + } + } + ] + }); + } + }; + } +}); diff --git a/packages/eslint-plugin-svelte/src/utils/rules.ts b/packages/eslint-plugin-svelte/src/utils/rules.ts index 3151d17f1..baf9f45b4 100644 --- a/packages/eslint-plugin-svelte/src/utils/rules.ts +++ b/packages/eslint-plugin-svelte/src/utils/rules.ts @@ -60,6 +60,7 @@ 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 preferStyleDirective from '../rules/prefer-style-directive.js'; +import preferWritableDerived from '../rules/prefer-writable-derived.js'; import requireEachKey from '../rules/require-each-key.js'; import requireEventDispatcherTypes from '../rules/require-event-dispatcher-types.js'; import requireOptimizedStyleAttribute from '../rules/require-optimized-style-attribute.js'; @@ -135,6 +136,7 @@ export const rules = [ preferConst, preferDestructuredStoreProps, preferStyleDirective, + preferWritableDerived, requireEachKey, requireEventDispatcherTypes, requireOptimizedStyleAttribute, diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/_requirements.json b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/_requirements.json new file mode 100644 index 000000000..0192b1098 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/_requirements.json @@ -0,0 +1,3 @@ +{ + "svelte": ">=5.0.0-0" +} diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/basic1-errors.yaml b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/basic1-errors.yaml new file mode 100644 index 000000000..600473adf --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/basic1-errors.yaml @@ -0,0 +1,15 @@ +- message: Prefer using writable $derived instead of $state and $effect + line: 4 + column: 6 + suggestions: + - desc: Rewrite $state and $effect to $derived + messageId: suggestRewrite + output: | + + + diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/basic1-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/basic1-input.svelte new file mode 100644 index 000000000..51523f5c3 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/basic1-input.svelte @@ -0,0 +1,10 @@ + + + diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/basic2-errors.yaml b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/basic2-errors.yaml new file mode 100644 index 000000000..b89390b86 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/basic2-errors.yaml @@ -0,0 +1,15 @@ +- message: Prefer using writable $derived instead of $state and $effect + line: 4 + column: 6 + suggestions: + - desc: Rewrite $state and $effect to $derived + messageId: suggestRewrite + output: | + + + diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/basic2-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/basic2-input.svelte new file mode 100644 index 000000000..e8b6769c6 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/basic2-input.svelte @@ -0,0 +1,10 @@ + + + diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/effect-pre1-errors.yaml b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/effect-pre1-errors.yaml new file mode 100644 index 000000000..600473adf --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/effect-pre1-errors.yaml @@ -0,0 +1,15 @@ +- message: Prefer using writable $derived instead of $state and $effect + line: 4 + column: 6 + suggestions: + - desc: Rewrite $state and $effect to $derived + messageId: suggestRewrite + output: | + + + diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/effect-pre1-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/effect-pre1-input.svelte new file mode 100644 index 000000000..fdafad808 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/effect-pre1-input.svelte @@ -0,0 +1,10 @@ + + + diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/effect-pre2-errors.yaml b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/effect-pre2-errors.yaml new file mode 100644 index 000000000..b89390b86 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/effect-pre2-errors.yaml @@ -0,0 +1,15 @@ +- message: Prefer using writable $derived instead of $state and $effect + line: 4 + column: 6 + suggestions: + - desc: Rewrite $state and $effect to $derived + messageId: suggestRewrite + output: | + + + diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/effect-pre2-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/effect-pre2-input.svelte new file mode 100644 index 000000000..ee0e91b2b --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/effect-pre2-input.svelte @@ -0,0 +1,10 @@ + + + diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/multiple-reassign1-errors.yaml b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/multiple-reassign1-errors.yaml new file mode 100644 index 000000000..f443ce89a --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/multiple-reassign1-errors.yaml @@ -0,0 +1,19 @@ +- message: Prefer using writable $derived instead of $state and $effect + line: 4 + column: 6 + suggestions: + - desc: Rewrite $state and $effect to $derived + messageId: suggestRewrite + output: | + + + diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/multiple-reassign1-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/multiple-reassign1-input.svelte new file mode 100644 index 000000000..52fce48ad --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/multiple-reassign1-input.svelte @@ -0,0 +1,14 @@ + + + diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/multiple-reassign2-errors.yaml b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/multiple-reassign2-errors.yaml new file mode 100644 index 000000000..d77136489 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/multiple-reassign2-errors.yaml @@ -0,0 +1,19 @@ +- message: Prefer using writable $derived instead of $state and $effect + line: 4 + column: 6 + suggestions: + - desc: Rewrite $state and $effect to $derived + messageId: suggestRewrite + output: | + + + { + newAlbumName = value; + }} + /> diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/multiple-reassign2-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/multiple-reassign2-input.svelte new file mode 100644 index 000000000..9f42e657a --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/multiple-reassign2-input.svelte @@ -0,0 +1,14 @@ + + + { + newAlbumName = value; + }} +/> diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/multiple-reassign3-errors.yaml b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/multiple-reassign3-errors.yaml new file mode 100644 index 000000000..0118d5e29 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/multiple-reassign3-errors.yaml @@ -0,0 +1,38 @@ +- message: Prefer using writable $derived instead of $state and $effect + line: 4 + column: 6 + suggestions: + - desc: Rewrite $state and $effect to $derived + messageId: suggestRewrite + output: | + + + +- message: Prefer using writable $derived instead of $state and $effect + line: 4 + column: 6 + suggestions: + - desc: Rewrite $state and $effect to $derived + messageId: suggestRewrite + output: | + + + diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/multiple-reassign3-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/multiple-reassign3-input.svelte new file mode 100644 index 000000000..bb6169e60 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/multiple-reassign3-input.svelte @@ -0,0 +1,14 @@ + + + diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/valid/_requirements.json b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/valid/_requirements.json new file mode 100644 index 000000000..0192b1098 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/valid/_requirements.json @@ -0,0 +1,3 @@ +{ + "svelte": ">=5.0.0-0" +} diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/valid/condition1-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/valid/condition1-input.svelte new file mode 100644 index 000000000..57bd65848 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/valid/condition1-input.svelte @@ -0,0 +1,13 @@ + + + diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/valid/condition2-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/valid/condition2-input.svelte new file mode 100644 index 000000000..9083881f1 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/valid/condition2-input.svelte @@ -0,0 +1,17 @@ + + + diff --git a/packages/eslint-plugin-svelte/tests/src/rules/prefer-writable-derived.ts b/packages/eslint-plugin-svelte/tests/src/rules/prefer-writable-derived.ts new file mode 100644 index 000000000..80e9065a7 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/src/rules/prefer-writable-derived.ts @@ -0,0 +1,12 @@ +import { RuleTester } from '../../utils/eslint-compat.js'; +import rule from '../../../src/rules/prefer-writable-derived.js'; +import { loadTestCases } from '../../utils/utils.js'; + +const tester = new RuleTester({ + languageOptions: { + ecmaVersion: 2020, + sourceType: 'module' + } +}); + +tester.run('prefer-writable-derived', rule as any, loadTestCases('prefer-writable-derived'));