🚚

Svelte 5 への移行手順

に公開

はじめに

最近プロジェクトが落ち着いてきて時間が取れるようになったので、今頃ですが、やっと Svelte 4 から 5 への移行作業に着手することになりました。ところが、これが予想していたよりも遙かに大変だったので、備忘録も兼ねて実際に移行した際の手順や躓きポイントをまとめておくことにします。これから Svelte 5 に移行する方の参考になれば幸いです。

移行を始める前に

プロジェクトの規模にもよりますが、Svelte 5 への移行作業は非常に時間がかかります。私が関わっているプロジェクトでは 60 以上のファイルに変更が必要になり、移行作業を始めてから main ブランチにマージしても問題ない状態に持っていくまで 2 週間以上かかりました。時間的な余裕がない場合は、無理に移行せずに Svelte 4 のまま開発を続けることをお勧めします。

Svelte 4 から 5 への移行手順

Svelte 5 公式移行ガイド:

https://svelte.dev/docs/svelte/v5-migration-guide

移行前の環境

Windows 10
Svelte 4.2.19
SvelteKit 2.19.0
Svelte Modals 1.3.0
TypeScript 5.7.3
Tailwind CSS 3.4.17

ステップ 1:移行スクリプトを実行する

  1. Svelte の公式が移行スクリプトを用意してくれているので、まずはそれを実行してみました。移行スクリプトを実行すると、Svelte 5 に移行する際に変更が必要な部分を検出し、「.svelte」ファイルのコードを自動で変更してくれます。ただし、あくまでも機械的に変更するだけなので、後で手作業による大幅な修正が必要でした。

  2. 移行スクリプトを実行するには、プロジェクトのルートディレクトリに移動してから次のコマンドを実行します。

    npx sv migrate svelte-5
    
  3. 「Which folders should be migrated」というメッセージの後に、プロジェクト内のディレクトリがすべて表示されるので、「src」ディレクトリのみを選択します。上下キーでディレクトリを移動し、スペースキーでディレクトリの選択をオン/オフできます。ディレクトリ名の頭にある選択表示が [ ・ ] になっていればオフ、[ + ] はオンです。

    注:
    このコマンドを VS Code のターミナルで実行すると、ディレクトリの選択表示が文字化けして [■] になる場合があります。その状態になっている場合、スペースキーを押して選択をオン/オフしても表示が変わらず、どのディレクトリが選択されているのか見分けが付きません。その場合は VS Code のターミナルではなく、システムのターミナル(ターミナル.app やコマンドプロンプト)を使いましょう。

  4. 「src」ディレクトリの選択表示のみが [ + ] になっていることを確認したら、enter キーを押して選択を確定します。移行スクリプトが実行されるので、完了するまで待ちましょう。

  5. この後 npm install を実行するわけですが、古いバージョンの Svelte Modals を使用している場合、Svelte 5 がサポートされていないためにエラーが発生することがあります。そのため、Svelte Modals のバージョンが ^0.2.0 より古い場合はこの段階でアップデートしておきましょう。まず次のコマンドで Svelte Modals をアンインストールします。

    npm uninstall svelte-modals
    

    次に、Svelte Modals を再インストールします。

    npm install svelte-modals
    
  6. node_modulespackage-lock.json を削除します。

  7. npm install を実行します。

  8. VS Code の Command Palette で Developer: Reload Window を実行します。移行スクリプトによる自動変更はこれで完了です。

ステップ 2:ESLint の動作確認

この後、移行スクリプトによって自動で変更されたコードを手作業で修正していくのですが、その前に ESLint の動作がおかしくないか確認しておきましょう。あくまで参考ですが、私の環境では .eslintrc.cjs を次のように変更するときちんと動作するようになりました。

.eslintrc.cjs
    parserOptions: {
-        extraFileExtensions: ['.svelte'],
+        extraFileExtensions: ['**/*.svelte', '.svelte ', '**/*.svelte.ts', '*.svelte.ts'],
    },

参考:

https://github.com/sveltejs/eslint-plugin-svelte/issues/587

ESLint がまともに動作することを確認できたら、移行スクリプトによって自動で変更されたコードを修正していきましょう。

ステップ 3:イベントハンドラのディレクティブからコロンを削除する

Svelte 5 ではイベントハンドラのディレクティブからコロンが削除されました。たとえば on:clickonclick に、on:keydownonkeydown になります。この変更は移行スクリプトによる自動変更だけで問題ないはずですが、念のため変更箇所を確認しておきましょう。

ステップ 4:コンポーネントにイベントハンドラを渡す方法を変更する

Svelte 5 以前はコンポーネントのタグに on:click={close} のようにイベントハンドラを指定できましたが、Svelte 5 ではできなくなりました。そのため、コンポーネント内でイベントハンドラを受け取るプロパティを定義して、それを使用する必要があります。次の例では、<SpecialButton> コンポーネントの on:click={close} の代わりに、コンポーネント内で handleClick プロパティを定義し、それを使って <SpecialButton> コンポーネントに close() を渡しています。

例:

- <SpecialButton on:click={close} title={title}>
+ <SpecialButton handleClick={close} title={title}>

ステップ 5:export let XXXlet XXX = $props() に変更する

Svelte 5 以前はコンポーネントのプロパティを export let XXX の形で定義していましたが、この部分は移行ツールによってすべて let XXX = $props() に変更されている筈です。

例(ユーザーの表示名を変更するダイアログボックス):

  <script lang="ts">
  import { closeModal } from 'svelte-modals';

- export let isOpen = false; // ダイアログボックスの表示状態
- export let title = ''; // タイトル
- export let displayName = ''; // ユーザーの表示名
- export let okBtnClick: () => void; // OK ボタンのクリックイベント
- export let cancelBtnClick: () => void = () => {}; // キャンセルボタンのクリックイベント
+ interface Props {
+   isOpen: boolean; // ダイアログボックスの表示状態
+   title: string; // タイトル
+   displayName?: string; // ユーザーの表示名
+   okBtnClick: () => void; // OK ボタンのクリックイベント
+   cancelBtnClick?: () => void; // キャンセルボタンのクリックイベント
+ }
+
+ let {
+   isOpen = false,
+   title = '',
+   displayName = '',
+   okBtnClick,
+   cancelBtnClick = (): void => {},
+ }: Props = $props();

  function close():void {
    closeModal();
  }
  </script>

一見すると上手い具合に変更されているように見えますが、Svelte Modals を使用している場合、このままでは型エラーになります。これを修正するには、svelte-modals から ModalProps 型をインポートして Props の型を ModalProps に変更する必要があります。

  <script lang="ts">
- import { closeModal } from 'svelte-modals';
+ import { closeModal, type ModalProps } from 'svelte-modals';

- interface Props {
+ interface Props extends ModalProps {
      isOpen: boolean;
      title: string;
      displayName?: string;
      okBtnClick: () => void;
      cancelBtnClick?: () => void;
      }

  let {
      isOpen = false,
      title = '',
      displayName = '',
      okBtnClick,
      cancelBtnClick = (): void => {},
  }: Props = $props();

  function close():void {
      closeModal();
  }
  </script>

また、ステップ 1 で Svelte Modals のバージョンを旧バージョンから最新にアップデートした場合、modals をインポートして openModal()closeModal() を変更する必要があります。

  <script lang="ts">
- import { closeModal, type ModalProps } from 'svelte-modals';
+ import { modals, type ModalProps } from 'svelte-modals';

  interface Props extends ModalProps {
      isOpen: boolean;
      title: string;
      displayName?: string;
      okBtnClick: () => void;
      cancelBtnClick?: () => void;
      }

  let {
      isOpen = false,
      title = '',
      displayName = '',
      okBtnClick,
      cancelBtnClick = (): void => {},
  }: Props = $props();

  function close():void {
-     closeModal();
+     modals.close();
  }
  </script>

Svelte Modals 公式移行ガイド:

https://svelte-modals.mattjennin.gs/migrations/v2

これで型エラーが解消できました!
しかし残念ながら、移行完了までにはまだまだ多くの関門があるのでした…

ステップ 6:$props の要素を bind で使う場合は $bindable を使用する

Svelte 5 では bind:value などの bind: ディレクティブで $props の要素を指定する場合、$bindable を使用して値を定義する必要があります。ところが、残念ながら移行スクリプトではこの変更が適切に行われません。必要なところには $bindable を使用せず、逆に不要なところに使用されているケースが多数見られました(変数名が form だったりすると、機械的に $bindable を使用する形に変更してしまうようです)。これは泣きながら手作業で修正するしかありません。

例(ユーザーの表示名を変更するダイアログボックス):

  <script lang="ts">
  import { closeModal, type ModalProps } from 'svelte-modals';

  interface Props extends ModalProps {
      isOpen: boolean;
      title: string;
      displayName?: string;
      okBtnClick: () => void;
      cancelBtnClick?: () => void;
      }

  let {
      isOpen = false,
      title = '',
-     displayName = '',
+     displayName = $bindable(''),
      okBtnClick,
      cancelBtnClick = (): void => {},
  }: Props = $props();
  let actionPath = '?/change_display_name';

  function close():void {
      closeModal();
  }
  </script>

  {#if isOpen}
      <div class="modal">
          <div class="modal-header">
              <h2>{title}</h2>
          </div>
          <div class="modal-body">
              <form method="POST" action={actionPath} use:enhance>
                  <div>
                      <label for="displayName">表示名:</label>
                      <input
                          id="displayName"
                          type="text"
                          bind:value={displayName}
                          required
                      />
                  </div>
                  <div>
                      <button class="cancel-button" onclick={cancelBtnClick}>キャンセル</button>
                      <button class="ok-button" onclick={okBtnClick} type="submit">OK</button>
                  </div>
              </form>
          </div>
      </div>
  {/if}

この例の場合、<input> タグで bind:value={displayName} を指定しているため、displayName の定義を $bindable('') に変更する必要があります。なお、この $bindable が適切に使用されていない場合、npm run dev を実行してブラウザで確認すると、検証ツールの「コンソール」タブに bind に関するエラーメッセージが表示されます。動作確認のときは、こうしたエラーを見逃さないように注意しましょう。

ステップ 7:リアクティブな値の宣言を確認/変更する

Svelte 5 ではリアクティブな値の宣言に $state を使用する必要があります。

例:

- let isLoading: boolean = false; // ロード中かどうかを示すフラグ
+ let isLoading = $state(false); // ロード中かどうかを示すフラグ

ところが、残念ながら移行スクリプトではこの変更が適切に行われません。特に不要なところに $state が使用されていたり、$derived(後述)を使用すべきところに機械的に $state を使用しているケースが多く見られました。これも見つけ次第、泣きながら手作業で修正する必要があります😭

ステップ 8:$: ラベルのブロックを rune 記法に変更する

Svelte 5 では $: ラベルのブロックを $effect または $derived を使用した rune 記法に変更する必要があります。ところが移行スクリプトを実行すると、$: ラベルのブロックはすべて svelte/legacy からインポートされる run で囲む形に機械的に変更されてしまうので、これも泣きながら手作業で修正する必要があります😭

例(画面遷移が完了するまでグレーアウト&カーソル形状が変わるボタン):

  <script lang="ts">
- import { run } from 'svelte/legacy';
  import { goto } from '$app/navigation';
  import { navigating } from '$app/state';

  let isNavigating = $state(false); // 画面遷移中かどうかを示すフラグ

  function handleClick(): void {
      goto('/'); // トップページに遷移する
  }

- run(() => {
+ $effect(() => {
      if (navigating.from || navigating.to)) {
          isNavigating = true;
      } else {
          isNavigating = false;
      }
  });
  </script>

  <div class="button-container">
      <button
          class="special-button {isNavigating ? 'cursor-wait' : 'cursor-pointer'}"
          disabled={isNavigating}
          onclick={handleClick}
      >
          トップページに戻る
      </button>
  </div>

なお、$effect ブロックはひとつのファイル内に複数記述できるので、依存する rune 変数ごとに分けて記述するようにしましょう。

ステップ 9:他の値に依存している値には $derived を使用する

Svelte 5 では、リアクティブな値が他の値に依存している場合、$state ではなく $derived を使用する必要があります。しかし残念ながら、移行スクリプトによる自動変更では $derived を使用すべきところに $state が使用されているケースが多く見られました。これも見つけ次第、泣きながら手作業で修正する必要があります😭

例(フォームの送信が成功したら、表示されている値を更新する):

  <script lang="ts">
- import { run } from 'svelte/legacy';
  import type { PageServerData } from './$types'
  import type { FormActionData } from '$lib/actions/type'
+ import { untrack } from 'svelte';

  interface Props {
      data: PageServerData; // DB から取得したデータ
      form: FormActionData; // フォームのデータ
  }

  let { data, form }: Props = $props();
  let actionPath = '?/change_display_name';
  let displayName = $state(data.user.displayName); // ユーザーの表示名
- let isSuccess = $state(false); // フォームの送信が成功したかどうかを示すフラグ
+ let isSuccess = $derived(form?.success ?? false); // フォームの送信が成功したかどうかを示すフラグ

- run(() => {
+ $effect(() => {
-    isSuccess = form?.success ?? false;
-
      if (isSuccess) {
+       untrack(() => {
              displayName = data.user.displayName; // 表示名を DB から取得した値で更新する
              isSuccess = false; // フラグをリセットする
+       });
      }
  });
  </script>

  <div class="form-container">
      <h2>表示名の変更</h2>
      <form method="POST" action={actionPath} use:enhance>
          <div>
              <label for="displayName">表示名:</label>
              <input
                  id="displayName"
                  type="text"
                  bind:value={displayName}
                  required
              />
          </div>
          <div>
              <button class="ok-button" onclick={okBtnClick} type="submit">OK</button>
          </div>
      </form>
  </div>

この例では、isSuccess の値が form.success に依存しているため、$state ではなく $derived を使用する必要がありますが、移行スクリプトで自動変更すると $state が使用されてしまいます。$state のままにしておいてもエラーは発生しませんが、form.success の値が変化しても isSuccess の値が更新されないため、if (isSuccess) 内の処理が実行されません。

もうひとつ重要な変更点があります。$effect 内でさらなるリアクティブ更新を生みたくない部分がある場合は untrack() で囲む必要があります。これを忘れると、$effect 内の $state$derived などの rune 変数を使用している箇所で無限ループが発生してしまいます。上の例では、if (isSuccess) 内を丸ごと untrack() で囲むことで、$effect 内の無限ループ発生を防いでいます。ブラウザの検証ツールの「コンソール」タブに effect_update_depth_exceeded エラーが表示された場合、無限ループが発生していると思われるので $effect 内のコードを見直しましょう。

ステップ 10:<slot /> を変更する

Svelte 5 では <slot /> を使用する代わりに Snippet 型のプロパティを親から受け取って {@render ...} タグで指定する形に変更する必要があります。残念ながら移行スクリプトはこうした変更を全くやってくれないので、泣きながら手作業で修正しなければなりません😭

例(+layout.svelte で子のコンテンツを表示する):

+layout.svelte
  <script lang="ts">
+ import type { Snippet } from 'svelte';
+
+ interface Props {
+   children?: Snippet;
+ }
+
+ let { children }: Props = $props();
  </script>

  <div class="layout">
      <header>
          <h1>My App</h1>
      </header>
      <main>
-       <slot />
+       {@render children?.()}
      </main>
      <footer>
          <p>&copy; 2025 My App</p>
      </footer>
  </div>

ステップ 11:<button> タグの問題を修正する

何時果てるともない修正作業にいいかげん涙も涸れ果てたことでしょうが、あともう少しです。頑張りましょう。

さて、Svelte 5 では html の <button> タグの書き方がかなり厳格になったようで、Svelte 4 では気にする必要がなかった箇所で問題が発生するようになりました。具体的に言うと、Svelte 4 のときは <td> タグを <button> タグで囲っても問題がなかったのですが、Svelte 5 では ESLint に怒られるようになりました。

例(ESLint に怒られる書き方):

<tbody>
    <tr>
        <button class="w-full" type="button" onclick={handleClick}>
            <td>
                <div class="flex justify-between">
                    <div class="flex justify-start hover:text-sky-500">
                        <div class="flex user-info">
                            <p>{userName}</p>
                        </div>
                    </div>
                    <div class="flex justify-end">
                        <SpecialButton handleClick={editUser}>
                            <EditIcon />
                        </SpecialButton>
                        <SpecialButton handleClick={deleteUser}>
                            <DeleteIcon />
                        </SpecialButton>
                    </div>
                </div>
            </td>
        </button>
    </tr>
</tbody>

これを次のように修正しました。

  <tbody>
      <tr>
-       <button class="w-full" type="button" onclick={handleClick}>
-           <td>
+       <td>
+            <button class="w-full" type="button" onclick={handleClick}>
                  <div class="flex justify-between">
                      <div class="flex justify-start hover:text-sky-500">
                          <div class="flex user-info">
                              <p>{userName}</p>
                          </div>
                      </div>
                      <div class="flex justify-end">
                          <SpecialButton handleClick={editUser}>
                              <EditIcon />
                          </SpecialButton>
                          <SpecialButton handleClick={deleteUser}>
                              <DeleteIcon />
                          </SpecialButton>
                      </div>
                  </div>
+            </button>
          </td>
-       </button>
      </tr>
  </tbody>

これで ESLint には怒られなくなり、ブラウザで確認しても一見問題がないように見えたのですが、画面をリロードするとレイアウトが大幅に崩れてしまいました。原因はおそらく <SpecialButton> コンポーネントの中で <button> タグを使用しているため、<button> タグが二重になっているせいではないかと思われます。次のように修正することで、ブラウザで画面をリロードしてもレイアウトが崩れなくなりました。

  <tbody>
      <tr>
          <td>
-           <button class="w-full" type="button" onclick={handleClick}>
-               <div class="flex justify-between">
+           <div class="flex justify-between">
+               <button class="w-full" type="button" onclick={handleClick}>
                      <div class="flex justify-start hover:text-sky-500">
                          <div class="flex user-info">
                              <p>{userName}</p>
                          </div>
                      </div>
+               </button>
                  <div class="flex justify-end">
                      <SpecialButton handleClick={editUser}>
                          <EditIcon />
                      </SpecialButton>
                      <SpecialButton handleClick={deleteUser}>
                          <DeleteIcon />
                      </SpecialButton>
                  </div>
+           </div>
-                </div>
-            </button>
          </td>
      </tr>
  </tbody>

ここまで修正すれば、ほとんど問題なく動作するようになるはずですが、まだ安心はできません。最後の力を振り絞って動作確認をしましょう。

ステップ 12:動作確認を行う

最後に npm run dev を実行して、ブラウザですべての機能が正常に動作することを確認しましょう。動作確認では、特に次の点に注意しましょう。

  • 検証ツールの「コンソール」タブにエラーが出ていないか
  • ブラウザ上で画面をリロードしたときに、レイアウトが崩れたりエラーが発生したりしないか

時間と気力の許す限り念入りに動作確認をして、問題がないことを確認してから main ブランチにマージしましょう。

パトラッシュ…疲れたろう?
僕も疲れたんだ。なんだかとても眠いんだ…
(˘ω˘) スヤァ…

参考記事

今回 Svelte 5 に移行するにあたっては、公式ドキュメント以外では次の記事を参考にさせていただきました。執筆者の皆様には深く感謝いたします。ありがとうございました🙏

Svelte5 の記法と移行方法:

https://qiita.com/kurata04/items/4c511e17fed7982aea1b

Svelte v5 で導入された Runes によるリアクティビティシステム:

https://azukiazusa.dev/blog/svelte-reactivity-system-with-runes/

svelte5 への移行(竹内修@筑波大):

https://dora.bk.tsukuba.ac.jp/~takeuchi/?プログラミング/svelte/svelte5への移行

svelte5 手順覚書(竹内修@筑波大):

https://dora.bk.tsukuba.ac.jp/~takeuchi/?プログラミング/svelte/svelte5手順覚書

おわりに

この記事は「忘れないうちに」と短時間で書き上げたものなので、間違いや書き漏らしもあるかと思います。もし何かお気付きの点があれば、コメントなどで教えていただけると大変有り難いです。よろしくお願いいたします。

Discussion