Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/stale-prop-derivation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@svelte-vitals/core': minor
'svelte-vitals': minor
'@svelte-vitals/vite': minor
'@svelte-vitals/mcp': minor
---

Add `correctness/stale-prop-derivation`: flags top-level values computed from `$props()` props without `$derived` and rendered in the template — they evaluate once at init and silently stop tracking the parent. Conservative by design: eager references only, call-free initializers, never-reassigned bindings, template-rendered. Also tweaks `correctness/unmutated-state`'s recommendation to point at `$derived` for prop-computed state.
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
---
title: correctness/stale-prop-derivation · Stale prop derivation
description: '$derived を使わずに prop から計算した値は一度しか評価されず、UI は気づかないうちに親の変更に追従しなくなります。'
---

**重大度:** warning · **カテゴリ:** correctness

## チェック内容

`$props()` の prop から `$derived` なしで計算され、テンプレートで描画されるトップレベルの `const`/`let` を検出します:

```svelte
<script>
let { type } = $props();

// 検出対象 — 初回レンダリングの値で固定される
let color = type === 'danger' ? 'red' : 'green';
</script>

<p class={color}>...</p>
```

検出は意図的に保守的にしてあり、次の条件をすべて満たすときだけ対象になります。初期化子が eager な位置で prop を参照していること(関数、アロー関数、getter の中の参照はリアクティブなままなので数えません)。初期化子が関数呼び出し、`new`、`await` を含まないこと(このため `$state(initial)` によるキャプチャ、`$derived`、サービスの構築は構造的に対象外です)。束縛が再代入も受け渡しもされないこと。そして実際にテンプレートで描画されていること(イベントハンドラーの中でしか使われない束縛は数えません)。

## なぜ重要か

Svelte のガイダンスは、props を変わるものとして扱うよう求めています。`$derived` を使わない素の代入は初期化時に一度だけ評価されるため、初回マウントでは正しく描画されますが、その後は親の変更に追従しなくなります。コンパイラも svelte-check も警告しないため、レビューをすり抜けて本番で発覚しがちな stale-UI バグです。

## 修正方法

```svelte
<script>
let { type } = $props();

let color = $derived(type === 'danger' ? 'red' : 'green');
</script>
```

関数本体が必要な計算には `$derived.by(() => ...)` を使ってください。一度きりのスナップショットが本当に必要な場合(非制御コンポーネントの初期値など)は、`let value = $state(initialValue)` が公式パターンで、これは検出対象になりません。

## 制限事項

関数呼び出しを含む式を対象外とする制限のため、メソッドを使った派生(`type.toUpperCase()`、`items.filter(...)`)は v1 では検出されません。これは精度を優先した意図的なトレードオフで、将来のバージョンで純粋な組み込みメソッドが allow-list に加わる可能性があります。また、親がその prop を実際に変えるかどうかは静的には分かりません。ただ、変えない場合でも `$derived` のコストはゼロで、変更が起きても正しく動くコードになります。`correctness/unmutated-state` との関係にも注意してください。prop から計算され、一度も書き込まれない `$state` の正しい修正は、`const` ではなく `$derived` です。

## 無効化

```js
// svelte-vitals.config.mjs
export default {
rules: {
'correctness/stale-prop-derivation': 'off'
}
};
```
54 changes: 54 additions & 0 deletions docs/src/content/docs/rules/correctness/stale-prop-derivation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
---
title: correctness/stale-prop-derivation · Stale prop derivation
description: 'A value computed from a prop without $derived is evaluated once — the UI silently stops tracking the parent.'
---

**Severity:** warning · **Category:** correctness

## What it checks

Flags a top-level `const`/`let` whose initializer is computed from a `$props()` prop without `$derived`, when that binding is rendered in the template:

```svelte
<script>
let { type } = $props();

// flagged — freezes the first render's value
let color = type === 'danger' ? 'red' : 'green';
</script>

<p class={color}>...</p>
```

Detection is deliberately conservative — all of these must hold: the initializer references a prop in an eager position (references inside functions/arrow bodies/getters stay reactive and don't count), contains no function calls, `new`, or `await` (so `$state(initial)` capture, `$derived`, and service construction are structurally exempt), the binding is never reassigned or passed around, and it is actually rendered (bindings used only inside event handlers don't count).

## Why it matters

Svelte's guidance is to treat props as though they will change. The plain form evaluates once, at initialization: the component renders correctly on first mount and silently stops tracking the parent afterwards — a stale-UI bug that survives review and surfaces in production, because nothing in the compiler or svelte-check warns about it.

## How to fix

```svelte
<script>
let { type } = $props();

let color = $derived(type === 'danger' ? 'red' : 'green');
</script>
```

Use `$derived.by(() => ...)` when the computation needs a function body. If you genuinely want a one-time snapshot (an uncontrolled component's initial value), `let value = $state(initialValue)` is the documented pattern — and it is not flagged.

## Limitations

The call-free restriction means method derivations (`type.toUpperCase()`, `items.filter(...)`) are not detected in v1 — a deliberate precision-first trade-off; a future version may allow-list pure built-ins. The rule cannot know whether the parent ever changes the prop; even when it doesn't, `$derived` costs nothing and keeps the code correct under change. Note the interplay with `correctness/unmutated-state`: for never-written `$state` computed from a prop, the right fix is `$derived`, not `const`.

## Disabling

```js
// svelte-vitals.config.mjs
export default {
rules: {
'correctness/stale-prop-derivation': 'off'
}
};
```
Loading
Loading