fix(ssr): resolve EventSource SSR violations and implement best practices
- Fix EventSource is not defined error in queue dashboard - Add browser guards for all EventSource usage - Replace static constants (EventSource.OPEN/CLOSED) with numeric values - Fix setInterval SSR violation in LLM health indicator - Replace $effect anti-pattern with onMount in share page - Add comprehensive SvelteKit SSR best practices documentation - Add SSR audit and testing verification All changes follow SvelteKit best practices and are verified against official documentation. Production build succeeds with no SSR errors. Closes: FixEventSourceSSR See: docs/outcomes/FixEventSourceSSR.md
This commit is contained in:
464
docs/SVELTEKIT_SSR_GUIDE.md
Normal file
464
docs/SVELTEKIT_SSR_GUIDE.md
Normal file
@@ -0,0 +1,464 @@
|
||||
# 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)
|
||||
Reference in New Issue
Block a user