Files
insta-recipe/docs/SVELTEKIT_SSR_GUIDE.md
Giancarmine Salucci 49bccf8f15 simplify
2026-02-18 01:21:44 +01:00

476 lines
10 KiB
Markdown

# SvelteKit SSR Best Practices Guide
This guide documents SSR-safe patterns and anti-patterns for InstaRecipe development. All developers should follow these guidelines to prevent SSR errors.
## Table of Contents
- [Core Principle](#core-principle)
- [Browser API Detection](#browser-api-detection)
- [Lifecycle Hooks](#lifecycle-hooks)
- [Runes and Reactivity](#runes-and-reactivity)
- [Common Gotchas](#common-gotchas)
- [Good Examples from Codebase](#good-examples-from-codebase)
- [Anti-Patterns to Avoid](#anti-patterns-to-avoid)
---
## Core Principle
**SvelteKit renders components on both the server and client.** Any browser-only APIs must be guarded or used in browser-only contexts.
### Browser-Only APIs (Require Guards)
- `window.*`
- `document.*`
- `localStorage`, `sessionStorage`
- `navigator.*`
- `EventSource`, `WebSocket`
- `location.*`
- `fetch` in certain contexts (use SvelteKit's built-in fetch in load functions)
---
## Browser API Detection
### Pattern: Use `browser` from `$app/environment`
```typescript
import { browser } from '$app/environment';
if (browser) {
// Safe: only runs in browser
const data = localStorage.getItem('key');
}
```
**Why it works:** `browser` is `true` only when running in the browser, `false` during SSR.
### Example: EventSource Connection
```svelte
<script lang="ts">
import { browser } from '$app/environment';
import { onMount } from 'svelte';
let eventSource = $state<EventSource | null>(null);
function startSSEConnection() {
if (!browser) return; // ✅ Guard
eventSource = new EventSource('/api/stream');
}
onMount(() => {
if (browser) { // ✅ Explicit guard
startSSEConnection();
}
});
</script>
```
---
## Lifecycle Hooks
### `onMount` - Browser-Only Lifecycle
**Use `onMount` for:**
- Browser API initialization
- Timer setup (`setInterval`, `setTimeout`)
- Event listener registration
- Any browser-only side effects
```typescript
import { onMount } from 'svelte';
onMount(() => {
// ✅ Only runs in browser (built-in SSR guard)
const interval = setInterval(() => {
// Polling logic
}, 1000);
return () => clearInterval(interval); // Cleanup
});
```
### `onDestroy` - Cleanup
```typescript
import { onDestroy } from 'svelte';
onDestroy(() => {
// ✅ Safe for cleanup
eventSource?.close();
});
```
---
## Runes and Reactivity
### `$state` - Reactive State
```typescript
let count = $state(0); // ✅ SSR-safe
let user = $state<User | null>(null); // ✅ SSR-safe with null default
// ❌ DON'T: Initialize with browser APIs
let stored = $state(localStorage.getItem('key')); // SSR crash!
// ✅ DO: Load in onMount
let stored = $state<string | null>(null);
onMount(() => {
stored = localStorage.getItem('key');
});
```
### `$derived` - Computed Values
```typescript
// ✅ GOOD: Pure computation
let doubled = $derived(count * 2);
let fullName = $derived(`${firstName} ${lastName}`);
// ❌ BAD: Side effects or browser APIs
let data = $derived(localStorage.getItem('key')); // SSR crash!
let userAgent = $derived(navigator.userAgent); // SSR crash!
```
**Rule:** `$derived` must be pure (no side effects, no browser APIs).
### `$effect` - Reactive Side Effects
**Critical:** `$effect` runs during **both SSR and hydration**. Always guard browser APIs!
```typescript
// ❌ BAD: No browser guard
$effect(() => {
setInterval(() => checkHealth(), 1000); // SSR crash!
});
// ✅ GOOD: With browser guard
$effect(() => {
if (!browser) return;
const interval = setInterval(() => checkHealth(), 1000);
return () => clearInterval(interval);
});
// ✅ BETTER: Use onMount for initialization instead
onMount(() => {
const interval = setInterval(() => checkHealth(), 1000);
return () => clearInterval(interval);
});
```
**When to use `$effect`:**
- Synchronizing derived state
- DOM manipulation (with browser guard)
- Reactive cleanup
**When NOT to use `$effect`:**
- Initialization (use `onMount`)
- API calls on mount (use `onMount`)
- Timer setup (use `onMount`)
---
## Common Gotchas
### 1. Static Constants from Browser APIs
```typescript
// ❌ BAD: Static properties don't exist during SSR
if (eventSource?.readyState === EventSource.OPEN) // SSR crash!
if (ws.readyState === WebSocket.OPEN) // SSR crash!
// ✅ GOOD: Use numeric constants
if (eventSource?.readyState === 1) // EventSource.OPEN = 1
if (ws.readyState === 1) // WebSocket.OPEN = 1
// ✅ GOOD: Guard the check
if (browser && eventSource?.readyState === EventSource.OPEN)
```
**EventSource States:**
- `EventSource.CONNECTING = 0`
- `EventSource.OPEN = 1`
- `EventSource.CLOSED = 2`
**WebSocket States:**
- `WebSocket.CONNECTING = 0`
- `WebSocket.OPEN = 1`
- `WebSocket.CLOSING = 2`
- `WebSocket.CLOSED = 3`
### 2. Event Handlers (Already Safe)
Event handlers (`onclick`, `onsubmit`, etc.) only run in the browser, so no guard needed:
```svelte
<button onclick={() => {
// ✅ Safe: event handlers only run in browser
localStorage.setItem('key', 'value');
}}>
Click me
</button>
```
### 3. Timers in Components
```typescript
// ❌ BAD: At module level
const interval = setInterval(() => {}, 1000); // SSR crash!
// ✅ GOOD: In onMount
onMount(() => {
const interval = setInterval(() => {}, 1000);
return () => clearInterval(interval);
});
```
### 4. Conditional Rendering Based on `browser`
```svelte
<!-- ⚠️ AVOID: Causes hydration mismatch -->
{#if browser}
<ClientOnlyComponent />
{/if}
<!-- ✅ BETTER: Initialize in onMount with flag -->
<script>
let mounted = $state(false);
onMount(() => {
mounted = true;
});
</script>
{#if mounted}
<ClientOnlyComponent />
{/if}
```
**Why:** The server renders one thing, the client renders another, causing hydration warnings.
---
## Good Examples from Codebase
### Example 1: PushNotificationManager (Excellent)
[src/lib/client/PushNotificationManager.ts](../src/lib/client/PushNotificationManager.ts)
```typescript
import { browser } from '$app/environment';
export class PushNotificationManager {
private static instance: PushNotificationManager | null = null;
static getInstance() {
if (!browser) return null; // ✅ Early return for SSR
// ... rest of implementation
}
private loadStoredSubscription() {
if (!browser) return null; // ✅ Guard localStorage
const stored = localStorage.getItem('pushSubscription');
return stored ? JSON.parse(stored) : null;
}
}
```
**Why it's good:**
- Guards all browser API access
- Early returns prevent unnecessary code execution during SSR
- Defensive programming with null checks
### Example 2: Queue Dashboard (Fixed)
[src/routes/+page.svelte](../src/routes/+page.svelte)
```svelte
<script lang="ts">
import { browser } from '$app/environment';
import { onMount } from 'svelte';
let eventSource = $state<EventSource | null>(null);
onMount(async () => {
await loadQueueItems();
if (browser) { // ✅ Guard
startSSEConnection();
}
});
function startSSEConnection() {
if (!browser) return; // ✅ Double guard for safety
eventSource = new EventSource('/api/queue/stream');
// ...
}
</script>
<!-- Template: Use numeric constants -->
<div class="indicator {eventSource?.readyState === 1 ? 'online' : 'offline'}"></div>
```
### Example 3: LLM Health Indicator (Fixed)
[src/routes/share/components/LlmHealthIndicator.svelte](../src/routes/share/components/LlmHealthIndicator.svelte)
```svelte
<script lang="ts">
import { onMount } from 'svelte';
onMount(() => {
// ✅ onMount only runs in browser
checkHealth(); // Initial check
const interval = setInterval(checkHealth, pollInterval);
return () => clearInterval(interval);
});
</script>
```
**Why it's good:**
- Uses `onMount` instead of `$effect` for initialization
- Timer setup in browser-only context
- Proper cleanup with return function
---
## Anti-Patterns to Avoid
### ❌ Anti-Pattern 1: Browser APIs in `$derived`
```typescript
// ❌ DON'T
let theme = $derived(localStorage.getItem('theme'));
// ✅ DO
let theme = $state<string | null>(null);
onMount(() => {
theme = localStorage.getItem('theme');
});
```
### ❌ Anti-Pattern 2: Side Effects in `$effect` Without Guards
```typescript
// ❌ DON'T
$effect(() => {
// Runs during SSR!
fetch('/api/data');
});
// ✅ DO: Guard browser-specific side effects
$effect(() => {
if (!browser) return;
fetch('/api/data');
});
// ✅ BETTER: Use onMount for initialization
onMount(() => {
fetch('/api/data');
});
```
### ❌ Anti-Pattern 3: Static Browser Constants
```typescript
// ❌ DON'T
if (ws.readyState === WebSocket.OPEN)
// ✅ DO
if (ws.readyState === 1) // WebSocket.OPEN = 1
```
### ❌ Anti-Pattern 4: Timers at Module Level
```typescript
// ❌ DON'T
const interval = setInterval(() => {}, 1000);
// ✅ DO
onMount(() => {
const interval = setInterval(() => {}, 1000);
return () => clearInterval(interval);
});
```
---
## Testing for SSR Safety
### 1. Build and Preview
```bash
npm run build
npm run preview
```
Watch server console for errors during build and preview.
### 2. Check for Hydration Warnings
Open browser DevTools console and look for:
- "Hydration failed"
- "The server response doesn't match the client content"
### 3. Search for Unguarded APIs
```bash
# Search for potential SSR issues
grep -r "window\." src/routes --include="*.svelte"
grep -r "document\." src/routes --include="*.svelte"
grep -r "localStorage" src/routes --include="*.svelte"
grep -r "navigator\." src/routes --include="*.svelte"
```
Then verify each usage is either:
- In an event handler (safe)
- In `onMount` (safe)
- Guarded with `if (browser)` (safe)
---
## Quick Reference Checklist
When writing component code, ask:
- [ ] Am I using any browser APIs? (`window`, `document`, `localStorage`, etc.)
- **Yes:** Add `browser` guard or use `onMount`
- **No:** Proceed normally
- [ ] Am I using `$effect`?
- **For synchronization:** OK, but guard browser APIs
- **For initialization:** Use `onMount` instead
- [ ] Am I using static properties from browser APIs?
- **Yes:** Use numeric constants or add `browser` guard
- **No:** You're good
- [ ] Does my code need cleanup?
- **Yes:** Return cleanup function from `onMount` or `$effect`
- **No:** You're good
---
## Further Reading
- [SvelteKit Documentation](https://kit.svelte.dev/docs)
- [Svelte Runes Documentation](https://svelte.dev/docs/svelte/$state)
- [MDN: EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource)
- [MDN: Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events)
---
**Document Version:** 1.0
**Last Updated:** December 22, 2025
**Related Outcome:** [FixEventSourceSSR](./outcomes/FixEventSourceSSR.md)