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
27 changes: 27 additions & 0 deletions .changeset/effect-rules-validity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@svelte-vitals/core': patch
---

Two `correctness/effect-*` rule content fixes from the v1.0 rule-validity review:

- `correctness/effect-as-onmount` no longer flags an `$effect` that reads a reactive value through
a member expression on an imported binding or a local declared with a `new …()` initializer — a
class instance with `$state` fields, a `SvelteMap`/`SvelteSet`, an imported runes-module state
object, and `svelte/reactivity/window` were all indistinguishable from the true mount-only
positive, and the rule's "use `onMount` instead" advice would have converted a re-running effect
into run-once. The change is strictly narrowing (only removes findings, never adds one); the
rule's message, recommendation, and docs now name `{@attach}` and event handlers alongside
`onMount` instead of presenting `onMount` as the sole fix, and the docs admit the remaining blind
spots (a reactive value reached only through a plain function's return value, or through a local
assigned `new …()` after its declaration instead of at it) plus a pre-existing shadowing
granularity note: matching is by identifier text, not lexical scope, so a callback-local binding
that shadows an imported or `new`-declared name is still treated as reactive — that can only
suppress a finding, never wrongly flag one.
- The documented fix snippets for `correctness/server-browser-global` and
`correctness/instance-browser-global` now use `onMount` (and, for the latter,
`svelte/reactivity/window` as the preferred modern form) instead of an `$effect` that assigns a
browser global to `$state` — that snippet was itself flagged by `correctness/effect-as-derived`,
whose "use `$derived`" advice would reintroduce the SSR `ReferenceError` those two rules exist to
prevent. `correctness/effect-as-derived`'s docs gain this as a second known limitation, and drop
an inaccurate "$derived updates synchronously" claim in favor of describing its actual push-pull,
lazy-recompute behavior.
28 changes: 26 additions & 2 deletions docs/src/content/docs/ja/rules/correctness/effect-as-derived.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ description: 状態を代入するだけの $effect は $derived に置き換え

## なぜ重要か

`$effect` で状態を同期する書き方(React の useEffect の習慣の持ち込み)は、レンダリング後に実行されるため、余計なレンダリングパスやループを招きます。`$derived` は同じ依存関係を宣言的に表し、同期的に更新されます。
`$effect` で状態を同期する書き方(React の useEffect の習慣の持ち込み)は、レンダリング後に実行されるため、余計なレンダリングパスやループを招きます。`$derived` は同じ依存関係を宣言的に表します — 個別の effect 実行をスケジュールするのではなく、次に読み取られたタイミングで遅延評価されます。

## 修正方法

Expand Down Expand Up @@ -40,4 +40,28 @@ description: 状態を代入するだけの $effect は $derived に置き換え
</script>
```

`$derived` は hydration 中も含めて即座に評価されますが、`$effect` は mount の1ティック後に実行されます。それがこのパターンの狙いです。この形を `$derived` に置き換えるのは `$effect` が防いでいたバグの再発なので、「修正」せず [`svelte-vitals-disable-next-line`](/ja/guides/cli#特定の指摘だけをインラインで抑制する) コメントで抑制してください。
`$derived` は hydration 中も含めて次に読まれたタイミングで再計算されますが、`$effect` はコンポーネントが DOM にマウントされた後にのみ実行されます。それがこのパターンの狙いです。この形を `$derived` に置き換えるのは `$effect` が防いでいたバグの再発なので、「修正」せず [`svelte-vitals-disable-next-line`](/ja/guides/cli#特定の指摘だけをインラインで抑制する) コメントで抑制してください。

## 既知の制限: browser global の捕捉

同じ構造的な死角は、[correctness/server-browser-global](/ja/rules/correctness/server-browser-global) と
[correctness/instance-browser-global](/ja/rules/correctness/instance-browser-global) が示している修正例
—— browser 専用の global を読んで `$state` に代入する `$effect` —— も検出してしまいます:

```svelte
<script>
let stored = $state(null);
$effect(() => {
stored = localStorage.getItem('filters'); // ここで検出されるが「修正」してはいけない
});
</script>
```

`$derived` は、その derived な値が何かに読まれた時点で初めて式を評価します —— ただ、この値は元々読まれる
ため(典型的にはテンプレートから)に存在し、テンプレートからの読み取りは SSR 中にも起こります。これを
`$derived(localStorage.getItem('filters'))` に置き換えると、サーバーサイドレンダリング中に誰かがそれを
読んだ瞬間、この2つのルールが防ごうとしている `ReferenceError: localStorage is not defined` を再発させます。
`localStorage`/`window` などは、まさに `$derived` が安全に読めない値です —— クライアント限定だと保証できる
読み取りが存在しないからです。`onMount`、あるいは `window` のプロパティなら
[`svelte/reactivity/window`](https://svelte.dev/docs/svelte/svelte-reactivity-window) を使ってください
(修正方法はこの2つのルールのドキュメントを参照)。`$derived` に切り替えるのではなく、この指摘は抑制してください。
20 changes: 17 additions & 3 deletions docs/src/content/docs/ja/rules/correctness/effect-as-onmount.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
---
title: correctness/effect-as-onmount · onMount 代わりの $effect
description: リアクティブな値を読まない $effect は、onMount で十分です。
description: reactive 値を読まない $effect は、イベントハンドラ、{@attach}、onMount のいずれかで書くべきです。
---

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

## チェック内容

本体が空ではないのに reactive 値を一切読まない `$effect` / `$effect.pre` を検出します。`$state`、`$derived`、`$props` の参照、store 購読、裸の関数呼び出し(`foo()`)のいずれもないものです。そのような effect はマウント後に一度だけ実行され、再実行されません。コンポーネントのインスタンススクリプトを静的(CLI)解析します。
本体が空ではないのに、このチェックから見える reactive 値を一切読まない `$effect` / `$effect.pre` を検出します。`$state`、`$derived`、`$props`、import されたバインディング、`new …()` を初期化子として宣言されたローカル変数、store 購読のいずれもなく、裸の関数呼び出し(`foo()`)もないものです。そのような effect はマウント後に一度だけ実行され、このチェックが追跡できる範囲では再実行されません。コンポーネントのインスタンススクリプトを静的(CLI)解析します。

## なぜ重要か

何にも反応しない `$effect` は実質 `onMount` です。`$effect` で書くとその意図が読み取りにくくなるうえ、リアクティビティの仕組みの誤用でもあります。`onMount` なら「マウント時に一度だけ実行する」という意図をそのまま表現できます。
何にも反応しない `$effect` は、多くの場合イベントハンドラ、`{@attach}`、`onMount` の代わりとして遠回りなだけです。`$effect` で書くとその意図が読み取りにくくなるうえ、リアクティビティの仕組みの誤用でもあります。

## 修正方法

Expand All @@ -24,3 +24,17 @@ description: リアクティブな値を読まない $effect は、onMount で
});
</script>
```

ユーザー操作への応答として実行しているなら、`$effect` よりイベントハンドラを優先してください。外部ライブラリ(チャートやツールチップなど)と要素を同期しているなら、[`{@attach ...}`](https://svelte.dev/docs/svelte/@attach)(5.29 以降)を優先してください — `$effect` や `onMount` より現在推奨されている DOM/ライブラリ同期の方法です。

## 既知の制限

このチェックは、rune の宣言(`$state`/`$derived`/`$props`)、import されたバインディング、`new …()` を初期化子として宣言されたローカル変数(`const x = new Foo()`)など、たどれる名前を経由した reactive 読み取りを認識します — これはクラスの `$state` フィールド、`SvelteMap`/`SvelteSet`、import された runes モジュールの状態オブジェクト、`svelte/reactivity/window` をカバーします。以下の2つの形はたどれる名前がないため、そのように書かれた本物の reactive な effect でも検出されてしまうことがあります:

- 素の関数の戻り値(`const c = createCounter()`)経由でしか到達できない reactive な値。
- 宣言時ではなく宣言後に `new …()` を代入したローカル変数(`let m; m = new SvelteMap();`)— 初期化子として宣言された形のみ認識されます。

名前の照合は字句スコープではなく識別子のテキストで行われます —— これは rune 名に対してこのルールが元々使っている粒度と同じです。そのため、import または `new` で宣言された名前をシャドーするコールバックローカルな binding(例えば同じ名前を再利用するパラメータ)も reactive として扱われます。これは指摘を見逃す方向にしか働かず、誤って検出することはありません。

該当する場合は、動作しているコードを `onMount` に移すのではなく、
[`svelte-vitals-disable-next-line`](/ja/guides/cli#特定の指摘だけをインラインで抑制する) コメントで抑制してください。
2 changes: 1 addition & 1 deletion docs/src/content/docs/ja/rules/correctness/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ description: svelte-vitals の Correctness ルール一覧。
| [`correctness/each-index-key`](/ja/rules/correctness/each-index-key) | 🟡 warning | &#123;#each&#125; のキーに index を使うと、要素の同一性が位置で決まります。キーを付けないのと同じバグが、見えにくい形で起こります。 |
| [`correctness/each-key`](/ja/rules/correctness/each-key) | 🟡 warning | 動的なデータを回す &#123;#each&#125; にはキーを付けましょう。 |
| [`correctness/effect-as-derived`](/ja/rules/correctness/effect-as-derived) | 🟡 warning | 状態を代入するだけの $effect は $derived に置き換えましょう。 |
| [`correctness/effect-as-onmount`](/ja/rules/correctness/effect-as-onmount) | 🟡 warning | リアクティブな値を読まない $effect は、onMount で十分です。 |
| [`correctness/effect-as-onmount`](/ja/rules/correctness/effect-as-onmount) | 🟡 warning | reactive 値を読まない $effect は、イベントハンドラ、&#123;@attach&#125;、onMount のいずれかで書くべきです。 |
| [`correctness/instance-browser-global`](/ja/rules/correctness/instance-browser-global) | 🟡 warning | インスタンススクリプトは SSR 時にサーバーでも実行されるため、トップレベルで window や document を参照するとレンダリングが落ちます。 |
| [`correctness/nonreactive-builtin-state`](/ja/rules/correctness/nonreactive-builtin-state) | 🟡 warning | $state に入れた素の Map・Set・Date・URL・URLSearchParams はプロキシされないため、中身を変えても検知されず、気づかないうちに UI が更新されなくなります。 |
| [`correctness/prop-mutation`](/ja/rules/correctness/prop-mutation) | 🟡 warning | $bindable を宣言していない $props() の値は書き換えないでください。 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,28 @@ description: インスタンススクリプトは SSR 時にサーバーでも
```svelte
<script>
const width = window.innerWidth; // ❌ SSR がクラッシュ
</script>
```

`window` のプロパティなら、[`svelte/reactivity/window`](https://svelte.dev/docs/svelte/svelte-reactivity-window)(5.11.0 以降)が現在推奨される形です — ガード不要で、サーバーでは `undefined`、クライアントではリアクティブになります:

```svelte
<script>
import { innerWidth } from 'svelte/reactivity/window';
</script>

<p>{innerWidth.current}</p>
```

`svelte/reactivity/window` がカバーしない場合は、`onMount` の中で読んでください — `onMount` はサーバーでは実行されません:

```svelte
<script>
import { onMount } from 'svelte';

let width2 = $state(0);
$effect(() => {
width2 = window.innerWidth; // ✅ クライアント専用
onMount(() => {
width2 = window.innerWidth; // ✅ onMount はサーバーでは実行されない
});
</script>
```
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,16 @@ export function load() {
}
```

ブラウザアクセスをクライアント側へ移します:
ブラウザアクセスをクライアント側、`onMount` の中へ移します — `onMount` はサーバーでは実行されません:

```svelte
<!-- +page.svelte -->
<script>
import { onMount } from 'svelte';

let stored = $state(null);
$effect(() => {
stored = localStorage.getItem('filters'); // ✅ effect はサーバーでは実行されない
onMount(() => {
stored = localStorage.getItem('filters'); // ✅ onMount はサーバーでは実行されない
});
</script>
```
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/ja/rules/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ svelte-vitals が報告するルールをカテゴリ別にまとめました。
| [`correctness/each-index-key`](/ja/rules/correctness/each-index-key) | 🟡 warning | &#123;#each&#125; のキーに index を使うと、要素の同一性が位置で決まります。キーを付けないのと同じバグが、見えにくい形で起こります。 |
| [`correctness/each-key`](/ja/rules/correctness/each-key) | 🟡 warning | 動的なデータを回す &#123;#each&#125; にはキーを付けましょう。 |
| [`correctness/effect-as-derived`](/ja/rules/correctness/effect-as-derived) | 🟡 warning | 状態を代入するだけの $effect は $derived に置き換えましょう。 |
| [`correctness/effect-as-onmount`](/ja/rules/correctness/effect-as-onmount) | 🟡 warning | リアクティブな値を読まない $effect は、onMount で十分です。 |
| [`correctness/effect-as-onmount`](/ja/rules/correctness/effect-as-onmount) | 🟡 warning | reactive 値を読まない $effect は、イベントハンドラ、&#123;@attach&#125;、onMount のいずれかで書くべきです。 |
| [`correctness/instance-browser-global`](/ja/rules/correctness/instance-browser-global) | 🟡 warning | インスタンススクリプトは SSR 時にサーバーでも実行されるため、トップレベルで window や document を参照するとレンダリングが落ちます。 |
| [`correctness/nonreactive-builtin-state`](/ja/rules/correctness/nonreactive-builtin-state) | 🟡 warning | $state に入れた素の Map・Set・Date・URL・URLSearchParams はプロキシされないため、中身を変えても検知されず、気づかないうちに UI が更新されなくなります。 |
| [`correctness/prop-mutation`](/ja/rules/correctness/prop-mutation) | 🟡 warning | $bindable を宣言していない $props() の値は書き換えないでください。 |
Expand Down
34 changes: 30 additions & 4 deletions docs/src/content/docs/rules/correctness/effect-as-derived.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Flags an `$effect` whose body only assigns to `$state` variables. Checked by sta

## Why it matters

Synchronising state with an `$effect` (the "useEffect → $effect" habit from React) runs after render and can trigger extra render passes or loops. A `$derived` value expresses the same dependency declaratively and updates synchronously.
Synchronising state with an `$effect` (the "useEffect → $effect" habit from React) runs after render and can trigger extra render passes or loops. A `$derived` value expresses the same dependency declaratively — Svelte recomputes it lazily, the next time it's read, rather than scheduling a separate effect run.

## How to fix

Expand Down Expand Up @@ -42,8 +42,34 @@ the "mount signal" idiom used to avoid SSR/prerender ↔ hydration mismatches:
</script>
```

`$derived` evaluates eagerly, including during hydration; `$effect` runs one tick after mount,
which is the whole point here. Converting this shape to `$derived` reintroduces the bug the
`$effect` was added to prevent — so suppress it with a
`$derived` recomputes on next read, including during hydration; `$effect` runs only after the
component has mounted to the DOM, which is the whole point here. Converting this shape to
`$derived` reintroduces the bug the `$effect` was added to prevent — so suppress it with a
[`svelte-vitals-disable-next-line`](/guides/cli#suppressing-a-single-finding-inline) comment
rather than "fixing" it.

## Known limitation: browser-global capture

The same structural blind spot also catches the documented fix for
[correctness/server-browser-global](/rules/correctness/server-browser-global) and
[correctness/instance-browser-global](/rules/correctness/instance-browser-global) — an `$effect` that
reads a browser-only global and assigns it to `$state`:

```svelte
<script>
let stored = $state(null);
$effect(() => {
stored = localStorage.getItem('filters'); // flagged here, but don't "fix" it
});
</script>
```

`$derived` only evaluates its expression when something reads the derived value — but a value like
this exists to be read (typically from the template), and a template read happens during SSR too.
Converting this to `$derived(localStorage.getItem('filters'))` would reintroduce the exact
`ReferenceError: localStorage is not defined` those two rules exist to prevent, the moment
something reads it during server-side rendering. `localStorage`/`window`/etc. are exactly the
values `$derived` cannot safely read, because there is no read that is guaranteed client-only.
Prefer `onMount`, or [`svelte/reactivity/window`](https://svelte.dev/docs/svelte/svelte-reactivity-window)
for `window` properties — see those two rules' docs for the fix — and suppress this finding rather
than switching to `$derived`.
Loading