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:
856
docs/plans/FixEventSourceSSR.md
Normal file
856
docs/plans/FixEventSourceSSR.md
Normal file
@@ -0,0 +1,856 @@
|
||||
# Execution Plan: Fix SSR Violations and SvelteKit Best Practices
|
||||
|
||||
## Outcome Name
|
||||
FixEventSourceSSRAndBestPractices
|
||||
|
||||
## Problem Analysis
|
||||
|
||||
### Primary Issue
|
||||
`ReferenceError: EventSource is not defined` at `/home/moze/Sources/insta-recipe/src/routes/+page.svelte:299:66`
|
||||
|
||||
### Root Cause
|
||||
The code is accessing `EventSource` during server-side rendering (SSR), but `EventSource` is a browser-only Web API that doesn't exist in Node.js. Additionally, comprehensive codebase analysis revealed multiple SSR violations and SvelteKit anti-patterns.
|
||||
|
||||
### Affected Files - Critical
|
||||
1. **[src/routes/+page.svelte](src/routes/+page.svelte)** - EventSource accessed at L299, L82 without browser guards
|
||||
2. **[src/routes/share/+page.svelte](src/routes/share/+page.svelte#L22-L25)** - `$effect` with side effects (calls `process()` function)
|
||||
3. **[src/routes/share/components/LlmHealthIndicator.svelte](src/routes/share/components/LlmHealthIndicator.svelte#L36-L39)** - `$effect` with `setInterval` (no browser guard)
|
||||
|
||||
### Affected Files - Already Compliant (Good Examples)
|
||||
1. **[src/lib/client/PushNotificationManager.ts](src/lib/client/PushNotificationManager.ts)** ✅
|
||||
- Properly uses `browser` from `$app/environment` (L10)
|
||||
- Guards `localStorage` access (L296, L300)
|
||||
- Guards `window.atob` access (L318)
|
||||
- Guards `navigator.serviceWorker` access (L111)
|
||||
|
||||
2. **[src/lib/client/ServiceWorkerMessageHandler.ts](src/lib/client/ServiceWorkerMessageHandler.ts)** ✅
|
||||
- All browser APIs properly used in client-only context
|
||||
- Not imported/used in SSR contexts
|
||||
|
||||
### SvelteKit Best Practices (from llms-full.txt documentation)
|
||||
|
||||
#### 1. Browser API Access
|
||||
**Pattern:** Import `browser` from `$app/environment` and guard all browser-only APIs
|
||||
```js
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
if (browser) {
|
||||
// Browser-only code
|
||||
}
|
||||
```
|
||||
|
||||
**Browser-only APIs to guard:**
|
||||
- `window.*`
|
||||
- `document.*`
|
||||
- `localStorage`, `sessionStorage`
|
||||
- `navigator.*`
|
||||
- `EventSource`, `WebSocket`
|
||||
- `location.*`
|
||||
|
||||
#### 2. Lifecycle Hooks
|
||||
**Pattern:** `onMount` only runs in browser (built-in SSR guard)
|
||||
```js
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
onMount(() => {
|
||||
// Automatically browser-only
|
||||
// Still good practice to add explicit browser check for clarity
|
||||
});
|
||||
```
|
||||
|
||||
#### 3. Runes and Reactivity
|
||||
**`$effect` gotcha:** Effects run during SSR AND hydration. Must guard browser APIs!
|
||||
```js
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
// Browser-only reactive code
|
||||
});
|
||||
```
|
||||
|
||||
**`$derived` gotcha:** Computed values run during SSR. Keep them pure!
|
||||
```js
|
||||
// ✅ GOOD - pure computation
|
||||
let doubled = $derived(count * 2);
|
||||
|
||||
// ❌ BAD - side effects in derived
|
||||
let value = $derived(localStorage.getItem('key')); // SSR crash!
|
||||
```
|
||||
|
||||
#### 4. State Initialization
|
||||
**Pattern:** Initialize with SSR-safe defaults, update in `onMount`
|
||||
```js
|
||||
let data = $state<Data | null>(null);
|
||||
|
||||
onMount(() => {
|
||||
// Load browser-only data
|
||||
data = JSON.parse(localStorage.getItem('key') || 'null');
|
||||
});
|
||||
```
|
||||
|
||||
#### 5. Static Constants
|
||||
**Gotcha:** Accessing static properties of browser APIs causes SSR errors
|
||||
```js
|
||||
// ❌ BAD - EventSource.OPEN doesn't exist in Node
|
||||
if (eventSource?.readyState === EventSource.OPEN)
|
||||
|
||||
// ✅ GOOD - Use numeric constants or guard
|
||||
if (browser && eventSource?.readyState === EventSource.OPEN)
|
||||
// OR
|
||||
if (eventSource?.readyState === 1) // EventSource.OPEN = 1
|
||||
```
|
||||
|
||||
### Codebase Analysis Results
|
||||
|
||||
#### ✅ Already Properly Guarded
|
||||
- `PushNotificationManager.ts` - Excellent example of SSR-safe patterns
|
||||
- `ServiceWorkerMessageHandler.ts` - Client-only, properly scoped
|
||||
- All API routes in `src/routes/api/**` - Server-only contexts
|
||||
- Service worker (`service-worker.ts`) - Runs in worker context only
|
||||
|
||||
#### ⚠️ Needs Fixing
|
||||
|
||||
**High Priority (Breaking SSR):**
|
||||
1. **+page.svelte (Queue Dashboard)**
|
||||
- L299: `eventSource?.readyState === EventSource.OPEN` - No browser guard
|
||||
- L82: `eventSource?.readyState === EventSource.CLOSED` - No browser guard
|
||||
- L67: `new EventSource()` - Inside `onMount` but needs explicit guard
|
||||
- Missing `browser` import
|
||||
|
||||
2. **LlmHealthIndicator.svelte**
|
||||
- L36-39: `$effect` with `setInterval` - No browser guard
|
||||
- Should use `onMount` instead for timer setup
|
||||
|
||||
**Medium Priority (Anti-patterns):**
|
||||
3. **share/+page.svelte**
|
||||
- L22-25: `$effect` calling `process()` with side effects
|
||||
- Should use `onMount` with conditional logic instead
|
||||
- `$effect` is meant for synchronization, not side effects
|
||||
|
||||
#### 📋 Not Issues (Clarifications)
|
||||
- `setTimeout` in components (L81 in +page.svelte, L53 in share/+page.svelte) - ✅ OK because inside `onMount` or event handlers
|
||||
- `goto` from `$app/navigation` - ✅ SSR-safe (SvelteKit handles this)
|
||||
- `$page` store from `$app/stores` - ✅ SSR-safe (available in both contexts)
|
||||
- Server-side code (`lib/server/**`) using browser automation - ✅ OK (different context, uses Puppeteer)
|
||||
|
||||
## Stories
|
||||
|
||||
## Stories
|
||||
|
||||
### Story 1: Fix EventSource SSR in Queue Dashboard
|
||||
**As a** developer
|
||||
**I want** to guard all EventSource usage from SSR execution
|
||||
**So that** the application doesn't crash with "EventSource is not defined"
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Import `browser` from `$app/environment`
|
||||
- Guard `EventSource` constructor in `startSSEConnection()`
|
||||
- Replace `EventSource.OPEN` constant with numeric value `1` or add browser guard
|
||||
- Replace `EventSource.CLOSED` constant with numeric value `2` or add browser guard
|
||||
- Connection status works correctly after hydration
|
||||
- No SSR errors in server logs
|
||||
|
||||
**Technical Details:**
|
||||
|
||||
**Lines to fix:**
|
||||
- L2: Add `import { browser } from '$app/environment';`
|
||||
- L67-68: Add browser guard before creating EventSource
|
||||
- L82: Change `EventSource.CLOSED` to `2` or guard with `browser`
|
||||
- L299: Change `EventSource.OPEN` to `1` or guard with `browser`
|
||||
|
||||
**Implementation:**
|
||||
```typescript
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
function startSSEConnection() {
|
||||
if (!browser) return; // ✅ Guard
|
||||
|
||||
try {
|
||||
eventSource = new EventSource('/api/queue/stream');
|
||||
// ... rest
|
||||
}
|
||||
}
|
||||
|
||||
// In reconnection logic (L82):
|
||||
if (browser && eventSource?.readyState === 2) { // CLOSED = 2
|
||||
startSSEConnection();
|
||||
}
|
||||
|
||||
// In template (L299):
|
||||
<div class="w-2 h-2 rounded-full {browser && eventSource?.readyState === 1 ? 'bg-green-400' : 'bg-red-400'}"></div>
|
||||
```
|
||||
|
||||
**Files:**
|
||||
- [src/routes/+page.svelte](src/routes/+page.svelte)
|
||||
|
||||
---
|
||||
|
||||
### Story 2: Fix $effect Anti-pattern in Share Page
|
||||
**As a** developer
|
||||
**I want** to replace `$effect` side effects with `onMount` pattern
|
||||
**So that** the code follows SvelteKit best practices
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Replace `$effect` with `onMount` for auto-processing shared URLs
|
||||
- No side effects in reactive expressions
|
||||
- Auto-processing still works when URL is shared
|
||||
- No unnecessary re-triggering
|
||||
|
||||
**Technical Details:**
|
||||
|
||||
According to SvelteKit documentation, `$effect` should be used for synchronization, not side effects like API calls. Use `onMount` instead.
|
||||
|
||||
**Current problematic code (L22-25):**
|
||||
```typescript
|
||||
$effect(() => {
|
||||
if (targetUrl && status === 'idle') {
|
||||
process();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Fixed code:**
|
||||
```typescript
|
||||
let hasProcessed = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
if (targetUrl && !hasProcessed) {
|
||||
hasProcessed = true;
|
||||
process();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Files:**
|
||||
- [src/routes/share/+page.svelte](src/routes/share/+page.svelte)
|
||||
|
||||
**SvelteKit Pattern Reference:**
|
||||
> Use `$effect` for synchronizing derived state, DOM manipulation, or reactive cleanup.
|
||||
> Use `onMount` for initialization, API calls, and browser-only setup.
|
||||
|
||||
---
|
||||
|
||||
### Story 3: Fix setInterval SSR in LLM Health Indicator
|
||||
**As a** developer
|
||||
**I want** to guard `setInterval` from SSR execution
|
||||
**So that** the component doesn't break during server rendering
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Add browser guard to `$effect` containing `setInterval`
|
||||
- Health polling only runs in browser
|
||||
- Component renders safely during SSR
|
||||
- Cleanup still works correctly
|
||||
|
||||
**Technical Details:**
|
||||
|
||||
**Current code (L36-39):**
|
||||
```typescript
|
||||
$effect(() => {
|
||||
checkHealth(); // Initial check
|
||||
const interval = setInterval(checkHealth, pollInterval);
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
```
|
||||
|
||||
**Fixed code:**
|
||||
```typescript
|
||||
$effect(() => {
|
||||
if (!browser) return; // ✅ SSR guard
|
||||
|
||||
checkHealth(); // Initial check
|
||||
const interval = setInterval(checkHealth, pollInterval);
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
```
|
||||
|
||||
**Better alternative - use onMount:**
|
||||
```typescript
|
||||
onMount(() => {
|
||||
checkHealth(); // Initial check
|
||||
const interval = setInterval(checkHealth, pollInterval);
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
```
|
||||
|
||||
**Files:**
|
||||
- [src/routes/share/components/LlmHealthIndicator.svelte](src/routes/share/components/LlmHealthIndicator.svelte)
|
||||
|
||||
---
|
||||
|
||||
### Story 4: Add SSR Best Practices Documentation
|
||||
**As a** developer
|
||||
**I want** documentation on SSR best practices for this project
|
||||
**So that** future development avoids these issues
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Create or update developer documentation
|
||||
- Include examples from the codebase
|
||||
- Reference SvelteKit official documentation
|
||||
- Add inline comments explaining SSR guards
|
||||
|
||||
**Technical Details:**
|
||||
|
||||
Create documentation covering:
|
||||
1. Browser API detection with `$app/environment`
|
||||
2. Lifecycle hook usage (`onMount` vs `$effect`)
|
||||
3. Common gotchas (static constants, timers, storage APIs)
|
||||
4. Good examples from our codebase (PushNotificationManager)
|
||||
|
||||
**Files to create/update:**
|
||||
- `docs/SVELTEKIT_SSR_GUIDE.md` (new)
|
||||
- Add inline comments to fixed files
|
||||
|
||||
---
|
||||
|
||||
### Story 5: Comprehensive SSR Audit and Testing
|
||||
**As a** developer
|
||||
**I want** to verify no other SSR violations exist
|
||||
**So that** the application is fully SSR-safe
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Manual SSR test: `npm run build && npm run preview`
|
||||
- Check server logs for any SSR errors
|
||||
- Test all routes with JavaScript disabled (progressive enhancement)
|
||||
- Verify hydration works correctly
|
||||
- No console warnings about hydration mismatches
|
||||
|
||||
**Technical Details:**
|
||||
|
||||
**Testing checklist:**
|
||||
- [ ] Build production bundle: `npm run build`
|
||||
- [ ] Preview production: `npm run preview`
|
||||
- [ ] Navigate to all routes
|
||||
- [ ] Check server console for errors
|
||||
- [ ] Verify SSE connection works
|
||||
- [ ] Test push notification UI
|
||||
- [ ] Test queue dashboard
|
||||
- [ ] Test share page with/without URL params
|
||||
|
||||
**Search patterns to verify:**
|
||||
```bash
|
||||
# Find any unguarded browser API usage
|
||||
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"
|
||||
```
|
||||
|
||||
**Known safe patterns:**
|
||||
- API routes (`src/routes/api/**`) - server-only
|
||||
- Client libraries (`src/lib/client/**`) - properly guarded
|
||||
- Event handlers (`onclick`, `onsubmit`) - run client-side
|
||||
- `onMount` callbacks - run client-side
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Critical Fixes (Blocks Production)
|
||||
**Priority:** URGENT - Fixes SSR crashes
|
||||
|
||||
1. **Story 1** - Fix EventSource in Queue Dashboard
|
||||
- Add `browser` import
|
||||
- Guard EventSource creation
|
||||
- Fix static constant references
|
||||
- Test SSR rendering
|
||||
|
||||
2. **Story 3** - Fix setInterval in LLM Health Indicator
|
||||
- Add browser guard to $effect OR convert to onMount
|
||||
- Test component SSR
|
||||
|
||||
**Estimated Time:** 30 minutes
|
||||
**Testing:** Build and preview, check server logs
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Best Practices (Improves Code Quality)
|
||||
**Priority:** HIGH - Fixes anti-patterns
|
||||
|
||||
3. **Story 2** - Fix $effect anti-pattern in Share Page
|
||||
- Replace $effect with onMount
|
||||
- Add processed flag to prevent re-runs
|
||||
- Test auto-processing behavior
|
||||
|
||||
**Estimated Time:** 20 minutes
|
||||
**Testing:** Test share target flow
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Validation & Documentation (Prevents Future Issues)
|
||||
**Priority:** MEDIUM - Long-term maintainability
|
||||
|
||||
4. **Story 5** - Comprehensive SSR Audit
|
||||
- Run production build
|
||||
- Test all routes
|
||||
- Verify no SSR errors
|
||||
|
||||
5. **Story 4** - Documentation
|
||||
- Create SSR best practices guide
|
||||
- Add inline comments
|
||||
- Document patterns from PushNotificationManager
|
||||
|
||||
**Estimated Time:** 1 hour
|
||||
**Testing:** Full regression test
|
||||
|
||||
---
|
||||
|
||||
### Total Estimated Time
|
||||
- Critical fixes: 30 min
|
||||
- Best practices: 20 min
|
||||
- Validation & docs: 1 hour
|
||||
- **Total: ~2 hours**
|
||||
|
||||
## Technical Specifications
|
||||
|
||||
### SvelteKit Runes Reference
|
||||
|
||||
#### `$state` - Reactive State
|
||||
```typescript
|
||||
let count = $state(0); // Simple state
|
||||
let obj = $state({ name: 'Alice' }); // Deep reactive proxy
|
||||
```
|
||||
- ✅ SSR-safe for primitive values
|
||||
- ⚠️ Don't initialize with browser APIs
|
||||
|
||||
#### `$derived` - Computed Values
|
||||
```typescript
|
||||
let doubled = $derived(count * 2);
|
||||
```
|
||||
- ✅ Runs during SSR
|
||||
- ⚠️ Must be pure (no side effects)
|
||||
- ❌ Don't access browser APIs
|
||||
|
||||
#### `$effect` - Reactive Side Effects
|
||||
```typescript
|
||||
$effect(() => {
|
||||
// Runs during SSR AND hydration
|
||||
console.log('count changed:', count);
|
||||
});
|
||||
```
|
||||
- ⚠️ Runs in both SSR and browser
|
||||
- ✅ Use for synchronization
|
||||
- ❌ Not for initialization or API calls
|
||||
- **Must guard browser APIs**
|
||||
|
||||
#### `onMount` - Browser-Only Lifecycle
|
||||
```typescript
|
||||
onMount(() => {
|
||||
// Only runs in browser
|
||||
return () => {
|
||||
// Cleanup
|
||||
};
|
||||
});
|
||||
```
|
||||
- ✅ Only runs in browser
|
||||
- ✅ Use for initialization
|
||||
- ✅ Use for browser API access
|
||||
|
||||
### Browser API Constants
|
||||
|
||||
Some browser APIs expose static constants that don't exist during SSR:
|
||||
|
||||
**EventSource:**
|
||||
- `EventSource.CONNECTING = 0`
|
||||
- `EventSource.OPEN = 1`
|
||||
- `EventSource.CLOSED = 2`
|
||||
|
||||
**Solutions:**
|
||||
```typescript
|
||||
// ❌ BAD - Crashes SSR
|
||||
if (es.readyState === EventSource.OPEN)
|
||||
|
||||
// ✅ GOOD - Use numeric value
|
||||
if (es.readyState === 1)
|
||||
|
||||
// ✅ GOOD - Guard access
|
||||
if (browser && es.readyState === EventSource.OPEN)
|
||||
```
|
||||
|
||||
**WebSocket:** Similar issue with `WebSocket.OPEN`, etc.
|
||||
|
||||
### Dependencies
|
||||
- `$app/environment` - Built-in SvelteKit module
|
||||
- No new package dependencies required
|
||||
|
||||
### Files to Modify
|
||||
|
||||
**Critical (Phase 1):**
|
||||
1. `src/routes/+page.svelte` - Queue dashboard
|
||||
2. `src/routes/share/components/LlmHealthIndicator.svelte` - Health indicator
|
||||
|
||||
**Best Practices (Phase 2):**
|
||||
3. `src/routes/share/+page.svelte` - Share page
|
||||
|
||||
**Documentation (Phase 3):**
|
||||
4. `docs/SVELTEKIT_SSR_GUIDE.md` - New file
|
||||
|
||||
### Code Patterns Summary
|
||||
|
||||
#### Pattern 1: Browser API in Component State
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let eventSource = $state<EventSource | null>(null);
|
||||
|
||||
onMount(() => {
|
||||
if (browser) {
|
||||
eventSource = new EventSource('/api/stream');
|
||||
}
|
||||
|
||||
return () => {
|
||||
eventSource?.close();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Use numeric constants or guard in template -->
|
||||
<div>Status: {eventSource?.readyState === 1 ? 'Connected' : 'Disconnected'}</div>
|
||||
```
|
||||
|
||||
#### Pattern 2: Timers and Intervals
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
onMount(() => {
|
||||
const interval = setInterval(() => {
|
||||
// Polling logic
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
#### Pattern 3: Auto-Processing on Mount
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let hasProcessed = $state(false);
|
||||
let data = $derived(computeData());
|
||||
|
||||
onMount(() => {
|
||||
if (shouldProcess && !hasProcessed) {
|
||||
hasProcessed = true;
|
||||
process();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
### High Priority Risks
|
||||
- **SSR/Hydration mismatch**: If guards are inconsistent between server and client
|
||||
- **Mitigation**: Use numeric constants; avoid conditional rendering based on `browser`
|
||||
- **Testing**: Check for hydration warnings in console
|
||||
|
||||
### Medium Priority Risks
|
||||
- **Regression in auto-processing**: Share page might not auto-process URLs
|
||||
- **Mitigation**: Thorough testing of share target flow
|
||||
- **Testing**: Test with Instagram share and manual URL input
|
||||
|
||||
- **Connection status flicker**: Status indicator might show wrong state briefly
|
||||
- **Mitigation**: Initialize with sensible defaults
|
||||
- **Testing**: Watch for visual flicker on page load
|
||||
|
||||
### Low Priority Risks
|
||||
- **Performance**: Minimal, browser checks are fast
|
||||
- **Breaking changes**: Unlikely, only fixing internal implementation
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Testing
|
||||
- Not applicable - these are integration-level fixes
|
||||
- Existing tests should continue to pass
|
||||
|
||||
### Integration Testing
|
||||
**Manual testing required:**
|
||||
|
||||
1. **SSR Testing:**
|
||||
```bash
|
||||
npm run build
|
||||
npm run preview
|
||||
# Check server console for errors
|
||||
# Navigate to all pages
|
||||
```
|
||||
|
||||
2. **EventSource Connection:**
|
||||
- Open queue dashboard
|
||||
- Check browser DevTools → Network → EventSource
|
||||
- Verify "Live updates" status indicator
|
||||
- Add queue item and verify real-time update
|
||||
|
||||
3. **Share Page:**
|
||||
- Navigate to `/share`
|
||||
- Manually enter URL → should work
|
||||
- Share from Instagram → should auto-process
|
||||
- Check no duplicate processing
|
||||
|
||||
4. **LLM Health:**
|
||||
- Check health indicator appears
|
||||
- Verify polling happens (check Network tab)
|
||||
- No SSR errors in console
|
||||
|
||||
### Edge Cases
|
||||
- **Server restart** while client connected → Reconnection works
|
||||
- **Network disconnection** → Graceful degradation
|
||||
- **JavaScript disabled** → Progressive enhancement (no errors)
|
||||
- **Multiple tabs** open → Each maintains own connection
|
||||
|
||||
### Hydration Testing
|
||||
- Disable JavaScript after SSR
|
||||
- Enable JavaScript and check hydration
|
||||
- Look for console warnings:
|
||||
- "Hydration failed"
|
||||
- "The server response doesn't match the client content"
|
||||
|
||||
### Browser Compatibility
|
||||
- Modern browsers with EventSource support
|
||||
- Browsers without EventSource → Should show disconnected status (no crash)
|
||||
|
||||
## Success Metrics
|
||||
|
||||
### Must Have (Phase 1)
|
||||
1. ✅ No `EventSource is not defined` errors
|
||||
2. ✅ No `setInterval is not defined` errors
|
||||
3. ✅ Production build succeeds
|
||||
4. ✅ SSR renders without errors
|
||||
5. ✅ Live updates work in browser
|
||||
|
||||
### Should Have (Phase 2)
|
||||
6. ✅ No `$effect` anti-patterns
|
||||
7. ✅ No hydration warnings
|
||||
8. ✅ Share page auto-processing works
|
||||
|
||||
### Nice to Have (Phase 3)
|
||||
9. ✅ SSR best practices documentation
|
||||
10. ✅ Inline comments explaining patterns
|
||||
11. ✅ All routes tested and verified
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
### ❌ Don't Do This
|
||||
|
||||
```typescript
|
||||
// 1. Browser API in $derived
|
||||
let data = $derived(localStorage.getItem('key')); // SSR crash!
|
||||
|
||||
// 2. Side effects in $effect without guard
|
||||
$effect(() => {
|
||||
fetch('/api/data'); // Runs during SSR!
|
||||
});
|
||||
|
||||
// 3. Static constants without guard
|
||||
if (ws.readyState === WebSocket.OPEN) // SSR crash!
|
||||
|
||||
// 4. Initialization in $effect
|
||||
$effect(() => {
|
||||
// Use onMount instead
|
||||
initialize();
|
||||
});
|
||||
```
|
||||
|
||||
### ✅ Do This Instead
|
||||
|
||||
```typescript
|
||||
// 1. Load in onMount
|
||||
let data = $state<string | null>(null);
|
||||
onMount(() => {
|
||||
data = localStorage.getItem('key');
|
||||
});
|
||||
|
||||
// 2. Guard browser APIs in $effect
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
fetch('/api/data');
|
||||
});
|
||||
|
||||
// 3. Use numeric constants or guard
|
||||
if (ws.readyState === 1) // WebSocket.OPEN = 1
|
||||
// OR
|
||||
if (browser && ws.readyState === WebSocket.OPEN)
|
||||
|
||||
// 4. Initialize in onMount
|
||||
onMount(() => {
|
||||
initialize();
|
||||
});
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
### Official Documentation
|
||||
- [SvelteKit SSR](https://svelte.dev/llms-full.txt) - From llms-full.txt
|
||||
- [Svelte Runes](https://svelte.dev/llms-full.txt) - $state, $derived, $effect
|
||||
- [SvelteKit $app modules](https://svelte.dev/llms-full.txt) - $app/environment, $app/stores
|
||||
|
||||
### Our Codebase Examples
|
||||
- **Good:** [PushNotificationManager.ts](src/lib/client/PushNotificationManager.ts) - Excellent SSR-safe patterns
|
||||
- **Good:** [ServiceWorkerMessageHandler.ts](src/lib/client/ServiceWorkerMessageHandler.ts) - Client-only scope
|
||||
- **Fix:** [+page.svelte](src/routes/+page.svelte) - EventSource needs guards
|
||||
- **Fix:** [LlmHealthIndicator.svelte](src/routes/share/components/LlmHealthIndicator.svelte) - setInterval needs guard
|
||||
- **Improve:** [share/+page.svelte](src/routes/share/+page.svelte) - $effect anti-pattern
|
||||
|
||||
### Web APIs
|
||||
- [EventSource MDN](https://developer.mozilla.org/en-US/docs/Web/API/EventSource)
|
||||
- [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events)
|
||||
|
||||
## Appendix: Complete Code Changes
|
||||
|
||||
### A. +page.svelte (Queue Dashboard)
|
||||
|
||||
**Before:**
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
|
||||
// ... state declarations
|
||||
|
||||
onMount(async () => {
|
||||
await loadQueueItems();
|
||||
startSSEConnection();
|
||||
});
|
||||
|
||||
function startSSEConnection() {
|
||||
try {
|
||||
eventSource = new EventSource('/api/queue/stream');
|
||||
// ...
|
||||
eventSource.addEventListener('error', (event) => {
|
||||
// ...
|
||||
setTimeout(() => {
|
||||
if (eventSource?.readyState === EventSource.CLOSED) {
|
||||
startSSEConnection();
|
||||
}
|
||||
}, 5000);
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Template -->
|
||||
<div class="w-2 h-2 rounded-full {eventSource?.readyState === EventSource.OPEN ? 'bg-green-400' : 'bg-red-400'}"></div>
|
||||
```
|
||||
|
||||
**After:**
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { browser } from '$app/environment';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
|
||||
// ... state declarations
|
||||
|
||||
onMount(async () => {
|
||||
await loadQueueItems();
|
||||
if (browser) {
|
||||
startSSEConnection();
|
||||
}
|
||||
});
|
||||
|
||||
function startSSEConnection() {
|
||||
if (!browser) return;
|
||||
|
||||
try {
|
||||
eventSource = new EventSource('/api/queue/stream');
|
||||
// ...
|
||||
eventSource.addEventListener('error', (event) => {
|
||||
// ...
|
||||
setTimeout(() => {
|
||||
if (eventSource?.readyState === 2) { // CLOSED = 2
|
||||
startSSEConnection();
|
||||
}
|
||||
}, 5000);
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Template -->
|
||||
<div class="w-2 h-2 rounded-full {browser && eventSource?.readyState === 1 ? 'bg-green-400' : 'bg-red-400'}"></div>
|
||||
```
|
||||
|
||||
### B. LlmHealthIndicator.svelte
|
||||
|
||||
**Before:**
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
// ...
|
||||
|
||||
$effect(() => {
|
||||
checkHealth();
|
||||
const interval = setInterval(checkHealth, pollInterval);
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
**After (Option 1 - Guard $effect):**
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
// ...
|
||||
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
|
||||
checkHealth();
|
||||
const interval = setInterval(checkHealth, pollInterval);
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
**After (Option 2 - Use onMount - RECOMMENDED):**
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
// ...
|
||||
|
||||
onMount(() => {
|
||||
checkHealth();
|
||||
const interval = setInterval(checkHealth, pollInterval);
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### C. share/+page.svelte
|
||||
|
||||
**Before:**
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
// ...
|
||||
let targetUrl = $derived(sharedUrl || extractUrl(sharedText));
|
||||
|
||||
$effect(() => {
|
||||
if (targetUrl && status === 'idle') {
|
||||
process();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
**After:**
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
// ...
|
||||
let targetUrl = $derived(sharedUrl || extractUrl(sharedText));
|
||||
let hasAutoProcessed = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
if (targetUrl && status === 'idle' && !hasAutoProcessed) {
|
||||
hasAutoProcessed = true;
|
||||
process();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Plan Complete - Ready for Implementation**
|
||||
Reference in New Issue
Block a user