mcpField
S
svelte5-runes-static

svelte5-runes-static

github.com/bobmatnyc開発78368 回インストール15 週間前に更新
リポジトリ

svelte5-runes-static について

adapter-static を使う開発者向けに、Svelte 5 runes で静的優先の SvelteKit アプリを構築する方法を解説します。プリレンダリングとハイドレーションに対応する状態管理や、グローバルストアとコンポーネント内 runes の連携を扱います。

主な機能

  1. 01プリレンダリング対応のグローバル状態管理
  2. 02ストアと rune のブリッジパターン
  3. 03ハイドレーション不一致の防止
  4. 04型付き Props とストア連携
  5. 05移行とテストのガイド

活用シーン

  1. 01adapter-static の状態管理方式の選定
  2. 02ハイドレーション後に状態が停止する問題の調査
  3. 03共有ストアとコンポーネント内 runes の併用

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/bobmatnyc/claude-mpm-skills --skill svelte5-runes-static

インストール後、Claude Code で/svelte5-runes-staticを入力して読み込まれたか確認します。

同梱ファイルmetadata.json

詳細

adapter-static を使用した Svelte 5 Runes(SvelteKit)

概要

ハイドレーションを損なうことなく、Svelte 5 の runes を使用して静的ファーストの SvelteKit アプリケーションを構築します。adapter-static(プリレンダリング)を使用し、グローバルストアとコンポーネントローカルの runes を組み合わせる際に、これらのパターンを適用してください。

関連スキル

  • svelte(Svelte 5 runes のコアパターン)
  • sveltekit(アダプター、デプロイ、SSR/SSG のパターン)
  • typescript-core(TypeScript のパターンとバリデーション)
  • vitest(ユニットテストのパターン)

コアとなる専門知識

プリレンダリングとハイドレーションを経ても適切に機能する状態管理パターンを用いて、runes モードで静的ファーストの Svelte 5 アプリケーションを構築します。

重要な互換性ルール

❌ 禁止:adapter-static でのモジュールスコープの runes

問題:静的プリレンダリング後、runes が正しくハイドレーションされません

// ❌ BROKEN - State becomes frozen after SSG
export function createStore() {
  let state = $state({ count: 0 });
  return {
    get count() { return state.count; },
    increment: () => { state.count++; }
  };
}

失敗する理由:

  • adapter-static はコンポーネントを HTML にプリレンダリングします
  • モジュールスコープの runes はシリアライズ/デシリアライズされません
  • ハイドレーション後、状態が不活性/固定状態になります
  • リアクティビティが完全に失われます

解決策:グローバル状態には従来の writable() ストアを使用します

// ✅ WORKS - Traditional stores hydrate correctly
import { writable } from 'svelte/store';

export function createStore() {
  const count = writable(0);
  return {
    count,
    increment: () => count.update(n => n + 1)
  };
}
❌ 禁止:$derived 内での $ 自動サブスクリプション

問題:runes モードでは $ による自動サブスクリプション構文が無効になります

// ❌ BROKEN - Can't use $ inside $derived
let filtered = $derived($events.filter(e => e.type === 'info'));
//                      ^^^^^^^ Error: $ not available in runes mode

解決策:$effect() 内でサブスクライブ → $state() を更新 → $derived() で使用します

// ✅ WORKS - Manual subscription pattern
import { type Writable } from 'svelte/store';

let events = $state<Event[]>([]);

$effect(() => {
  const unsub = eventsStore.subscribe(value => {
    events = value;
  });
  return unsub;
});

let filtered = $derived(events.filter(e => e.type === 'info'));
❌ 禁止:ゲッターを使用したストアファクトリー

問題:ゲッターではリアクティブな接続が確立されません

// ❌ BROKEN - Getter pattern breaks reactivity
export function createSocketStore() {
  const socket = writable<Socket | null>(null);
  return {
    get socket() { return socket; }, // ❌ Not reactive
    connect: () => { /* ... */ }
  };
}

解決策:ストアを直接エクスポートします

// ✅ WORKS - Direct store exports
export function createSocketStore() {
  const socket = writable<Socket | null>(null);
  const isConnected = derived(socket, $s => $s?.connected ?? false);

  return {
    socket,          // ✅ Direct store reference
    isConnected,     // ✅ Direct derived reference
    connect: () => { /* ... */ }
  };
}

推奨されるハイブリッドパターン

グローバル状態:従来のストア

SSG/SSR を経ても維持する必要がある状態には、writable()/derived() を使用します:

// stores/globalState.ts
import { writable, derived } from 'svelte/store';

export const user = writable<User | null>(null);
export const theme = writable<'light' | 'dark'>('light');
export const isAuthenticated = derived(user, $u => $u !== null);
コンポーネントの状態:Svelte 5 Runes

コンポーネントローカルの状態やロジックには runes を使用します:

<script lang="ts">
import { user } from '$lib/stores/globalState';

// Props with runes
let {
  initialCount = 0,
  onUpdate = () => {}
}: {
  initialCount?: number;
  onUpdate?: (count: number) => void;
} = $props();

// Bridge: Store → Rune State
let currentUser = $state<User | null>(null);
$effect(() => {
  const unsub = user.subscribe(u => {
    currentUser = u;
  });
  return unsub;
});

// Component-local state
let count = $state(initialCount);
let doubled = $derived(count * 2);

// Effects
$effect(() => {
  if (count > 10) {
    onUpdate(count);
  }
});

function increment() {
  count++;
}
</script>

<button onclick={increment}>
  {currentUser?.name ?? 'Guest'}: {count} (×2 = {doubled})
</button>

完全なブリッジパターン

ストア → Rune → Derived の連鎖
<script lang="ts">
import { type Writable } from 'svelte/store';

// 1. Import global stores (traditional)
const { events: eventsStore, filters: filtersStore } = myGlobalStore;

// 2. Bridge to rune state
let events = $state<Event[]>([]);
let activeFilters = $state<string[]>([]);

$effect(() => {
  const unsubEvents = eventsStore.subscribe(v => { events = v; });
  const unsubFilters = filtersStore.subscribe(v => { activeFilters = v; });

  return () => {
    unsubEvents();
    unsubFilters();
  };
});

// 3. Derived computations (pure runes)
let filtered = $derived(
  events.filter(e =>
    activeFilters.length === 0 ||
    activeFilters.includes(e.category)
  )
);

let count = $derived(filtered.length);
let hasEvents = $derived(count > 0);
</script>

{#if hasEvents}
  <p>Found {count} events</p>
  {#each filtered as event}
    <EventCard {event} />
  {/each}
{:else}
  <p>No events match filters</p>
{/if}

SSG/SSR に関する考慮事項

プリレンダリングに安全なパターン
// ✅ Safe for prerendering
export const load = async ({ fetch }) => {
  const data = await fetch('/api/data').then(r => r.json());
  return { data };
};
<script lang="ts">
import { browser } from '$app/environment';

let { data } = $props();

// ✅ Client-only initialization
$effect(() => {
  if (browser) {
    // WebSocket, localStorage, etc.
    initializeClientOnlyFeatures();
  }
});
</script>
ハイドレーションの不一致を防止する
// ✅ Avoid hydration mismatches
let timestamp = $state<number | null>(null);

$effect(() => {
  if (browser) {
    timestamp = Date.now(); // Only set on client
  }
});

{#if browser}
  <LiveClock />
{:else}
  <p>Loading clock...</p>
{/if}

TypeScript との統合

Runes を使用した型付き Props
<script lang="ts">
import type { Snippet } from 'svelte';

interface Props {
  title: string;
  count?: number;
  items: Array<{ id: string; name: string }>;
  onSelect?: (id: string) => void;
  children?: Snippet;
}

let {
  title,
  count = 0,
  items,
  onSelect = () => {},
  children
}: Props = $props();

let selected = $state<string | null>(null);
let filteredItems = $derived(
  items.filter(item =>
    selected === null || item.id === selected
  )
);
</script>

<h2>{title} ({count})</h2>

{#each filteredItems as item}
  <button onclick={() => onSelect(item.id)}>
    {item.name}
  </button>
{/each}

{@render children?.()}
型付きストアブリッジ
<script lang="ts">
import type { Writable, Readable } from 'svelte/store';

interface StoreShape {
  data: Writable<string[]>;
  status: Readable<'loading' | 'ready' | 'error'>;
}

const stores: StoreShape = getMyStores();

let data = $state<string[]>([]);
let status = $state<'loading' | 'ready' | 'error'>('loading');

$effect(() => {
  const unsubData = stores.data.subscribe(v => { data = v; });
  const unsubStatus = stores.status.subscribe(v => { status = v; });
  return () => {
    unsubData();
    unsubStatus();
  };
});

let isEmpty = $derived(data.length === 0);
let isReady = $derived(status === 'ready');
</script>

一般的なパターン

バインド可能なコンポーネント状態
<script lang="ts">
let {
  value = $bindable(''),
  disabled = false
}: {
  value?: string;
  disabled?: boolean;
} = $props();

let focused = $state(false);
let charCount = $derived(value.length);
let isValid = $derived(charCount >= 3 && charCount <= 100);
</script>

<input
  bind:value
  {disabled}
  onfocus={() => { focused = true; }}
  onblur={() => { focused = false; }}
  class:focused
  class:invalid={!isValid}
/>
<p>{charCount}/100</p>
フォームの状態管理
<script lang="ts">
interface FormData {
  email: string;
  password: string;
}

let formData = $state<FormData>({
  email: '',
  password: ''
});

let errors = $state<Partial<Record<keyof FormData, string>>>({});

let isValid = $derived(
  formData.email.includes('@') &&
  formData.password.length >= 8
);

let canSubmit = $derived(
  isValid && Object.keys(errors).length === 0
);

function validate(field: keyof FormData) {
  if (field === 'email' && !formData.email.includes('@')) {
    errors.email = 'Invalid email';
  } else if (field === 'password' && formData.password.length < 8) {
    errors.password = 'Password too short';
  } else {
    delete errors[field];
  }
}

async function handleSubmit() {
  if (!canSubmit) return;

  // Submit logic
  const result = await submitForm(formData);

  if (result.ok) {
    // Success
  } else {
    errors = result.errors;
  }
}
</script>

<form onsubmit={handleSubmit}>
  <input
    type="email"
    bind:value={formData.email}
    onblur={() => validate('email')}
  />
  {#if errors.email}
    <span class="error">{errors.email}</span>
  {/if}

  <input
    type="password"
    bind:value={formData.password}
    onblur={() => validate('password')}
  />
  {#if errors.password}
    <span class="error">{errors.password}</span>
  {/if}

  <button type="submit" disabled={!canSubmit}>
    Submit
  </button>
</form>
デバウンス付き検索
<script lang="ts">
import { writable, derived } from 'svelte/store';

const searchQuery = writable('');

// Traditional derived store with debounce
const debouncedQuery = derived(
  searchQuery,
  ($query, set) => {
    const timeout = setTimeout(() => set($query), 300);
    return () => clearTimeout(timeout);
  },
  '' // initial value
);

// Bridge to rune state
let query = $state('');
let debouncedValue = $state('');

$effect(() => {
  searchQuery.set(query);
});

$effect(() => {
  const unsub = debouncedQuery.subscribe(v => {
    debouncedValue = v;
  });
  return unsub;
});

// Use in derived
let results = $derived(
  debouncedValue.length >= 3
    ? performSearch(debouncedValue)
    : []
);
</script>

<input
  type="search"
  bind:value={query}
  placeholder="Search..."
/>

{#each results as result}
  <SearchResult {result} />
{/each}

移行チェックリスト

Svelte 4 から adapter-static を使用する Svelte 5 に移行する場合:

  • コンポーネントレベルの $: を $derived() に置き換える
  • export let prop を let { prop } = $props() に置き換える
  • グローバルストアは writable()/derived() のままにする
  • ストア → rune の状態ブリッジパターンを追加する
  • $store 構文を $effect() 内の手動サブスクリプションに置き換える
  • npm run build でプリレンダリングをテストする
  • ハイドレーションが正しく機能することを確認する
  • コンソールでハイドレーションの不一致がないか確認する
  • browser チェックでクライアント専用コードをガードする

テストパターン

Runes のユニットテスト
import { mount } from 'svelte';
import { tick } from 'svelte';
import { describe, it, expect } from 'vitest';
import Counter from './Counter.svelte';

describe('Counter', () => {
  it('increments count', async () => {
    const { component } = mount(Counter, {
      target: document.body,
      props: { initialCount: 0 }
    });

    const button = document.querySelector('button');
    button?.click();

    await tick();

    expect(button?.textContent).toContain('1');
  });
});
ストアブリッジのテスト
import { get } from 'svelte/store';
import { tick } from 'svelte';
import { describe, it, expect } from 'vitest';
import { createMyStore } from './myStore';

describe('Store Bridge', () => {
  it('syncs store to rune state', async () => {
    const store = createMyStore();

    store.data.set(['item1', 'item2']);

    await tick();

    expect(get(store.data)).toEqual(['item1', 'item2']);
  });
});

パフォーマンスに関する考慮事項

不要なリアクティビティを避ける
// ❌ Over-reactive
let items = $state([1, 2, 3, 4, 5]);
let doubled = $derived(items.map(x => x * 2));
let tripled = $derived(items.map(x => x * 3));
let quadrupled = $derived(items.map(x => x * 4));

// ✅ Compute only what's needed
let items = $state([1, 2, 3, 4, 5]);
let transformedItems = $derived(
  mode === 'double' ? items.map(x => x * 2) :
  mode === 'triple' ? items.map(x => x * 3) :
  items.map(x => x * 4)
);
コストの高い計算をメモ化する
// Traditional derived store for expensive computations
const expensiveComputation = derived(
  [source1, source2],
  ([$s1, $s2]) => {
    // Expensive calculation
    return complexAlgorithm($s1, $s2);
  }
);

// Bridge to rune
let result = $state(null);
$effect(() => {
  const unsub = expensiveComputation.subscribe(v => { result = v; });
  return unsub;
});

トラブルシューティング

症状:ハイドレーション後に状態が更新されない

原因:adapter-static でのモジュールスコープの runes

解決策:グローバル状態には従来の writable() ストアを使用します

症状:$derived 内で「$ is not defined」エラーが発生する

原因:runes モードで $store 構文を使用しようとしている

解決策:$effect() のサブスクリプションを使ったブリッジパターンを使用します

症状:SSG 後に「Cannot read property of undefined」が発生する

原因:直接エクスポートではなく、ゲッターを使用したストアファクトリー

解決策:ゲッターでラップせず、ストアを直接エクスポートします

症状:ハイドレーションの不一致に関する警告が表示される

原因:SSR 中にクライアント専用の状態がレンダリングされている

解決策:browser チェックでガードするか、{#if browser} を使用します

選択の指針

従来のストアを使用する場合:

  • 状態をSSG/SSRのプリレンダリング後も保持する必要がある場合
  • コンポーネント間で状態をグローバルに共有する場合
  • 状態をシリアライズ/デシリアライズする必要がある場合
  • adapter-static を使用する場合

ルーンを使用する場合:

  • 状態がコンポーネント内に限定される場合
  • リアクティブなUIロジックを構築する場合
  • プロパティやコンポーネントのライフサイクルを扱う場合
  • ローカル状態から派生する計算を作成する場合

ブリッジパターンを使用する場合:

  • グローバルストアとコンポーネントのルーンを組み合わせる必要がある場合
  • ストアの値から派生する計算を行いたい場合
  • 複雑なリアクティブチェーンを構築する場合

関連スキル

  • toolchains-javascript-frameworks-svelte:Svelteの基本的なパターン
  • toolchains-typescript-core:TypeScriptとの統合
  • toolchains-ui-styling-tailwind:Svelteコンポーネントのスタイリング
  • toolchains-javascript-testing-vitest:Svelte 5のテスト

参考資料

コメント