forked from mirror/crumbs-original
chore: add agent skills for Svelte 5, SvelteKit, TypeScript, and Drizzle
Install project-level skills via skills.sh CLI: - svelte5-best-practices (runes, snippets, events, migration) - sveltekit-structure (routing, layouts, error boundaries) - sveltekit-data-flow (load functions, form actions, serialization) - typescript-best-practices (type-first development, discriminated unions) - drizzle-migrations (SQLite schema, query patterns) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
0894c10c95
commit
02e6b57764
@@ -0,0 +1,423 @@
|
||||
---
|
||||
name: drizzle-migrations
|
||||
description: Drizzle ORM schema management and SQLite migrations — adding tables, modifying columns, creating indexes, generating and running migrations, Drizzle query patterns. NOT for Prisma, TypeORM,
|
||||
Sequelize, or raw SQL migration tools.
|
||||
allowed-tools: Read,Write,Edit,Bash(npm:*,npx:*)
|
||||
metadata:
|
||||
category: Data & Analytics
|
||||
tags:
|
||||
- database
|
||||
- drizzle
|
||||
- migrations
|
||||
pairs-with:
|
||||
- skill: database-design-patterns
|
||||
reason: Schema migration implementation follows database design pattern decisions
|
||||
- skill: supabase-admin
|
||||
reason: Drizzle migrations run against Supabase PostgreSQL databases with RLS considerations
|
||||
- skill: fullstack-debugger
|
||||
reason: Migration failures and schema mismatches are common fullstack debugging scenarios
|
||||
---
|
||||
|
||||
# Drizzle ORM Migrations
|
||||
|
||||
This skill helps you manage database schema changes using Drizzle ORM with SQLite.
|
||||
|
||||
## When to Use
|
||||
|
||||
✅ **USE this skill for:**
|
||||
- Adding new tables or modifying existing columns
|
||||
- Generating and running database migrations
|
||||
- Drizzle-specific query patterns and relations
|
||||
- SQLite schema best practices with Drizzle
|
||||
- Setting up Drizzle configuration
|
||||
|
||||
❌ **DO NOT use for:**
|
||||
- Supabase/PostgreSQL → use `supabase-admin` skill
|
||||
- Raw SQL without Drizzle → use standard SQL resources
|
||||
- Prisma ORM → different syntax and patterns
|
||||
- General database design theory → use database architecture resources
|
||||
|
||||
## Project Setup
|
||||
|
||||
**Configuration**: `drizzle.config.ts`
|
||||
```typescript
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
export default defineConfig({
|
||||
schema: './src/db/schema.ts',
|
||||
out: './drizzle',
|
||||
dialect: 'sqlite',
|
||||
dbCredentials: {
|
||||
url: './data/app.db',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Commands**:
|
||||
```bash
|
||||
npm run db:generate # Generate migration files
|
||||
npm run db:push # Push schema directly (dev only)
|
||||
npm run db:studio # Open Drizzle Studio GUI
|
||||
```
|
||||
|
||||
## Schema Definition
|
||||
|
||||
Location: `src/db/schema.ts`
|
||||
|
||||
### Table Definition
|
||||
|
||||
```typescript
|
||||
import { sqliteTable, text, integer, real, blob } from 'drizzle-orm/sqlite-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
|
||||
// Basic table
|
||||
export const users = sqliteTable('users', {
|
||||
id: text('id').primaryKey(),
|
||||
email: text('email').notNull().unique(),
|
||||
username: text('username').notNull(),
|
||||
passwordHash: text('password_hash'),
|
||||
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text('updated_at'),
|
||||
});
|
||||
|
||||
// Table with foreign key
|
||||
export const checkIns = sqliteTable('check_ins', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id').notNull().references(() => users.id, {
|
||||
onDelete: 'cascade',
|
||||
}),
|
||||
mood: integer('mood').notNull(),
|
||||
cravingLevel: integer('craving_level').notNull(),
|
||||
sleepHours: real('sleep_hours'),
|
||||
notes: text('notes'),
|
||||
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
// Table with composite index
|
||||
export const auditLog = sqliteTable('audit_log', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id').notNull(),
|
||||
action: text('action').notNull(),
|
||||
targetType: text('target_type'),
|
||||
targetId: text('target_id'),
|
||||
details: text('details'), // JSON string
|
||||
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
}, (table) => ({
|
||||
userActionIdx: index('idx_audit_user_action').on(table.userId, table.action),
|
||||
createdAtIdx: index('idx_audit_created').on(table.createdAt),
|
||||
}));
|
||||
```
|
||||
|
||||
### Relations
|
||||
|
||||
```typescript
|
||||
export const usersRelations = relations(users, ({ many }) => ({
|
||||
checkIns: many(checkIns),
|
||||
sessions: many(sessions),
|
||||
journalEntries: many(journalEntries),
|
||||
}));
|
||||
|
||||
export const checkInsRelations = relations(checkIns, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [checkIns.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
```
|
||||
|
||||
## Column Types
|
||||
|
||||
### SQLite Types in Drizzle
|
||||
|
||||
```typescript
|
||||
import {
|
||||
sqliteTable,
|
||||
text, // TEXT - strings, JSON, dates
|
||||
integer, // INTEGER - numbers, booleans (0/1)
|
||||
real, // REAL - floating point
|
||||
blob, // BLOB - binary data
|
||||
} from 'drizzle-orm/sqlite-core';
|
||||
|
||||
const examples = sqliteTable('examples', {
|
||||
// Strings
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
|
||||
// Numbers
|
||||
count: integer('count').notNull().default(0),
|
||||
rating: real('rating'),
|
||||
|
||||
// Booleans (stored as 0/1)
|
||||
isActive: integer('is_active', { mode: 'boolean' }).default(true),
|
||||
|
||||
// Dates (stored as ISO strings)
|
||||
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
expiresAt: text('expires_at'),
|
||||
|
||||
// JSON (stored as TEXT)
|
||||
metadata: text('metadata', { mode: 'json' }),
|
||||
|
||||
// Enums (stored as TEXT)
|
||||
status: text('status', { enum: ['pending', 'active', 'archived'] }),
|
||||
});
|
||||
```
|
||||
|
||||
## Migration Strategies
|
||||
|
||||
### Strategy 1: Push (Development Only)
|
||||
|
||||
```bash
|
||||
npm run db:push
|
||||
```
|
||||
|
||||
- Directly applies schema changes
|
||||
- Fast for development
|
||||
- **Never use in production**
|
||||
|
||||
### Strategy 2: Generate & Migrate (Production)
|
||||
|
||||
```bash
|
||||
# 1. Generate migration file
|
||||
npm run db:generate
|
||||
|
||||
# 2. Review generated SQL in /drizzle folder
|
||||
|
||||
# 3. Apply migration (in code or manually)
|
||||
```
|
||||
|
||||
### Applying Migrations in Code
|
||||
|
||||
```typescript
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
const sqlite = new Database('./data/app.db');
|
||||
const db = drizzle(sqlite);
|
||||
|
||||
// Run migrations
|
||||
migrate(db, { migrationsFolder: './drizzle' });
|
||||
```
|
||||
|
||||
## Common Schema Changes
|
||||
|
||||
### Adding a New Table
|
||||
|
||||
```typescript
|
||||
// 1. Add to schema.ts
|
||||
export const newFeature = sqliteTable('new_feature', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
name: text('name').notNull(),
|
||||
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
// 2. Add relations
|
||||
export const newFeatureRelations = relations(newFeature, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [newFeature.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
// 3. Generate migration
|
||||
// npm run db:generate
|
||||
```
|
||||
|
||||
### Adding a Column
|
||||
|
||||
```typescript
|
||||
// In schema.ts, add the new column
|
||||
export const users = sqliteTable('users', {
|
||||
// existing columns...
|
||||
newColumn: text('new_column'), // Add this
|
||||
});
|
||||
|
||||
// Generate migration
|
||||
// npm run db:generate
|
||||
```
|
||||
|
||||
### Adding an Index
|
||||
|
||||
```typescript
|
||||
export const messages = sqliteTable('messages', {
|
||||
id: text('id').primaryKey(),
|
||||
conversationId: text('conversation_id').notNull(),
|
||||
createdAt: text('created_at').notNull(),
|
||||
}, (table) => ({
|
||||
// Add index
|
||||
convCreatedIdx: index('idx_messages_conv_created')
|
||||
.on(table.conversationId, table.createdAt),
|
||||
}));
|
||||
```
|
||||
|
||||
### Renaming (Requires Manual SQL)
|
||||
|
||||
SQLite doesn't support direct column renames in older versions. For complex changes:
|
||||
|
||||
```sql
|
||||
-- drizzle/XXXX_rename_column.sql
|
||||
-- Manual migration for column rename
|
||||
|
||||
-- 1. Create new table with desired schema
|
||||
CREATE TABLE users_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL, -- renamed from username
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 2. Copy data
|
||||
INSERT INTO users_new SELECT id, email, username, created_at FROM users;
|
||||
|
||||
-- 3. Drop old table
|
||||
DROP TABLE users;
|
||||
|
||||
-- 4. Rename new table
|
||||
ALTER TABLE users_new RENAME TO users;
|
||||
```
|
||||
|
||||
## Query Patterns
|
||||
|
||||
### Basic Queries
|
||||
|
||||
```typescript
|
||||
import { db } from '@/db';
|
||||
import { eq, and, or, desc, asc, like, gte, lte } from 'drizzle-orm';
|
||||
import { users, checkIns } from '@/db/schema';
|
||||
|
||||
// Select all
|
||||
const allUsers = await db.select().from(users);
|
||||
|
||||
// Select with conditions
|
||||
const activeUsers = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.isActive, true));
|
||||
|
||||
// Select specific columns
|
||||
const userEmails = await db
|
||||
.select({ id: users.id, email: users.email })
|
||||
.from(users);
|
||||
|
||||
// Complex where clause
|
||||
const results = await db
|
||||
.select()
|
||||
.from(checkIns)
|
||||
.where(
|
||||
and(
|
||||
eq(checkIns.userId, userId),
|
||||
gte(checkIns.createdAt, startDate),
|
||||
lte(checkIns.createdAt, endDate)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(checkIns.createdAt))
|
||||
.limit(30);
|
||||
```
|
||||
|
||||
### Insert
|
||||
|
||||
```typescript
|
||||
// Single insert
|
||||
const [newUser] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
id: generateId(),
|
||||
email: 'user@example.com',
|
||||
username: 'newuser',
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Bulk insert
|
||||
await db.insert(checkIns).values([
|
||||
{ id: '1', userId, mood: 7, cravingLevel: 2 },
|
||||
{ id: '2', userId, mood: 8, cravingLevel: 1 },
|
||||
]);
|
||||
|
||||
// Upsert (insert or update)
|
||||
await db
|
||||
.insert(users)
|
||||
.values({ id: 'user-1', email: 'new@example.com' })
|
||||
.onConflictDoUpdate({
|
||||
target: users.id,
|
||||
set: { email: 'new@example.com' },
|
||||
});
|
||||
```
|
||||
|
||||
### Update
|
||||
|
||||
```typescript
|
||||
await db
|
||||
.update(users)
|
||||
.set({ username: 'newname', updatedAt: new Date().toISOString() })
|
||||
.where(eq(users.id, userId));
|
||||
```
|
||||
|
||||
### Delete
|
||||
|
||||
```typescript
|
||||
// Always use WHERE clause!
|
||||
await db
|
||||
.delete(checkIns)
|
||||
.where(eq(checkIns.id, checkInId));
|
||||
|
||||
// Delete with multiple conditions
|
||||
await db
|
||||
.delete(sessions)
|
||||
.where(
|
||||
and(
|
||||
eq(sessions.userId, userId),
|
||||
lte(sessions.expiresAt, new Date().toISOString())
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
### Joins
|
||||
|
||||
```typescript
|
||||
const userWithCheckIns = await db
|
||||
.select({
|
||||
user: users,
|
||||
checkIn: checkIns,
|
||||
})
|
||||
.from(users)
|
||||
.leftJoin(checkIns, eq(users.id, checkIns.userId))
|
||||
.where(eq(users.id, userId));
|
||||
```
|
||||
|
||||
### Aggregations
|
||||
|
||||
```typescript
|
||||
import { count, avg, sum, max, min } from 'drizzle-orm';
|
||||
|
||||
const stats = await db
|
||||
.select({
|
||||
totalCheckIns: count(),
|
||||
avgMood: avg(checkIns.mood),
|
||||
maxStreak: max(checkIns.streak),
|
||||
})
|
||||
.from(checkIns)
|
||||
.where(eq(checkIns.userId, userId));
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always use transactions for related changes**
|
||||
```typescript
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(users).values(userData);
|
||||
await tx.insert(profiles).values(profileData);
|
||||
});
|
||||
```
|
||||
|
||||
2. **Always include WHERE on DELETE/UPDATE**
|
||||
3. **Use indexes for frequently queried columns**
|
||||
4. **Store dates as ISO strings for SQLite**
|
||||
5. **Use `returning()` to get inserted/updated rows**
|
||||
6. **Generate migrations, don't push to production**
|
||||
|
||||
## References
|
||||
|
||||
- [Drizzle ORM Docs](https://orm.drizzle.team/docs/overview)
|
||||
- [Drizzle SQLite](https://orm.drizzle.team/docs/get-started-sqlite)
|
||||
- [Drizzle Migrations](https://orm.drizzle.team/docs/migrations)
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
name: svelte5-best-practices
|
||||
description: "Svelte 5 runes, snippets, SvelteKit patterns, and modern best practices for TypeScript and component development. Use when writing, reviewing, or refactoring Svelte 5 components and SvelteKit applications. Triggers on: Svelte components, runes ($state, $derived, $effect, $props, $bindable, $inspect), snippets ({#snippet}, {@render}), event handling, SvelteKit data loading, form actions, Svelte 4 to Svelte 5 migration, store to rune migration, slots to snippets migration, TypeScript props typing, generic components, SSR state isolation, performance optimization, or component testing."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: ejirocodes
|
||||
version: '1.0.0'
|
||||
---
|
||||
|
||||
# Svelte 5 Best Practices
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Topic | When to Use | Reference |
|
||||
|-------|-------------|-----------|
|
||||
| **Runes** | $state, $derived, $effect, $props, $bindable, $inspect | [runes.md](references/runes.md) |
|
||||
| **Snippets** | Replacing slots, {#snippet}, {@render} | [snippets.md](references/snippets.md) |
|
||||
| **Events** | onclick handlers, callback props, context API | [events.md](references/events.md) |
|
||||
| **TypeScript** | Props typing, generic components | [typescript.md](references/typescript.md) |
|
||||
| **Migration** | Svelte 4 to 5, stores to runes | [migration.md](references/migration.md) |
|
||||
| **SvelteKit** | Load functions, form actions, SSR, page typing | [sveltekit.md](references/sveltekit.md) |
|
||||
| **Performance** | Universal reactivity, avoiding over-reactivity, streaming | [performance.md](references/performance.md) |
|
||||
|
||||
## Essential Patterns
|
||||
|
||||
### Reactive State
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let count = $state(0); // Reactive state
|
||||
let doubled = $derived(count * 2); // Computed value
|
||||
</script>
|
||||
```
|
||||
|
||||
### Component Props
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let { name, count = 0 } = $props();
|
||||
let { value = $bindable() } = $props(); // Two-way binding
|
||||
</script>
|
||||
```
|
||||
|
||||
### Snippets (replacing slots)
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let { children, header } = $props();
|
||||
</script>
|
||||
|
||||
{@render header?.()}
|
||||
{@render children()}
|
||||
```
|
||||
|
||||
### Event Handlers
|
||||
|
||||
```svelte
|
||||
<!-- Svelte 5: use onclick, not on:click -->
|
||||
<button onclick={() => count++}>Click</button>
|
||||
```
|
||||
|
||||
### Callback Props (replacing createEventDispatcher)
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let { onclick } = $props();
|
||||
</script>
|
||||
|
||||
<button onclick={() => onclick?.({ data })}>Click</button>
|
||||
```
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
1. **Using `let` without `$state`** - Variables are not reactive without `$state()`
|
||||
2. **Using `$effect` for derived values** - Use `$derived` instead
|
||||
3. **Using `on:click` syntax** - Use `onclick` in Svelte 5
|
||||
4. **Using `createEventDispatcher`** - Use callback props instead
|
||||
5. **Using `<slot>`** - Use snippets with `{@render}`
|
||||
6. **Forgetting `$bindable()`** - Required for `bind:` to work
|
||||
7. **Setting module-level state in SSR** - Causes cross-request leaks
|
||||
8. **Sequential awaits in load functions** - Use `Promise.all` for parallel requests
|
||||
@@ -0,0 +1,353 @@
|
||||
# Svelte 5 Events Reference
|
||||
|
||||
## Table of Contents
|
||||
- [Event Handler Syntax](#event-handler-syntax)
|
||||
- [Callback Props Pattern](#callback-props-pattern)
|
||||
- [Context API](#context-api)
|
||||
|
||||
---
|
||||
|
||||
## Event Handler Syntax
|
||||
|
||||
Svelte 5 replaces `on:click` directive syntax with standard HTML attribute syntax `onclick`.
|
||||
|
||||
### Basic Event Handlers
|
||||
|
||||
**Svelte 4:**
|
||||
```svelte
|
||||
<button on:click={handleClick}>Click</button>
|
||||
<input on:input={handleInput} />
|
||||
<form on:submit={handleSubmit}>...</form>
|
||||
```
|
||||
|
||||
**Svelte 5:**
|
||||
```svelte
|
||||
<button onclick={handleClick}>Click</button>
|
||||
<input oninput={handleInput} />
|
||||
<form onsubmit={handleSubmit}>...</form>
|
||||
```
|
||||
|
||||
### Event Modifiers Migration
|
||||
|
||||
Event modifiers no longer exist. Use wrapper functions:
|
||||
|
||||
**Svelte 4:**
|
||||
```svelte
|
||||
<form on:submit|preventDefault={handleSubmit}>...</form>
|
||||
<button on:click|stopPropagation={handleClick}>...</button>
|
||||
```
|
||||
|
||||
**Svelte 5:**
|
||||
```svelte
|
||||
<script>
|
||||
function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
// ... handle form
|
||||
}
|
||||
|
||||
function handleClick(event) {
|
||||
event.stopPropagation();
|
||||
// ... handle click
|
||||
}
|
||||
</script>
|
||||
|
||||
<form onsubmit={handleSubmit}>...</form>
|
||||
<button onclick={handleClick}>...</button>
|
||||
```
|
||||
|
||||
### Capture, Passive, and NonPassive
|
||||
|
||||
```svelte
|
||||
<!-- Capture phase -->
|
||||
<div onclickcapture={handleCapture}>
|
||||
<button onclick={handleClick}>Click</button>
|
||||
</div>
|
||||
|
||||
<!-- Passive listener -->
|
||||
<div ontouchstartpassive={handleTouch}>...</div>
|
||||
|
||||
<!-- Non-passive -->
|
||||
<div ontouchmovenonpassive={(e) => e.preventDefault()}>...</div>
|
||||
```
|
||||
|
||||
### Inline Handlers
|
||||
|
||||
```svelte
|
||||
<button onclick={() => count++}>Count: {count}</button>
|
||||
<input oninput={(e) => name = e.target.value} />
|
||||
```
|
||||
|
||||
### Event Handler Shorthand
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
function onclick(event) {
|
||||
console.log('Clicked!', event);
|
||||
}
|
||||
</script>
|
||||
|
||||
<button {onclick}>Click</button>
|
||||
```
|
||||
|
||||
### Spreading Event Handlers
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let handlers = {
|
||||
onclick: () => console.log('clicked'),
|
||||
onmouseenter: () => console.log('entered'),
|
||||
onmouseleave: () => console.log('left')
|
||||
};
|
||||
</script>
|
||||
|
||||
<button {...handlers}>Hover or Click</button>
|
||||
```
|
||||
|
||||
### Multiple Handlers for Same Event
|
||||
|
||||
In Svelte 5, combine logic into one handler:
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
function handleClick(event) {
|
||||
handler1(event);
|
||||
handler2(event);
|
||||
}
|
||||
</script>
|
||||
|
||||
<button onclick={handleClick}>...</button>
|
||||
```
|
||||
|
||||
### TypeScript Event Typing
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
function handleClick(event: MouseEvent) {
|
||||
console.log(event.clientX, event.clientY);
|
||||
}
|
||||
|
||||
function handleInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
console.log(target.value);
|
||||
}
|
||||
|
||||
function handleSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(event.currentTarget as HTMLFormElement);
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Callback Props Pattern
|
||||
|
||||
Svelte 5 deprecates `createEventDispatcher` in favor of callback props for component-to-parent communication.
|
||||
|
||||
### Basic Event Pattern
|
||||
|
||||
**Svelte 4:**
|
||||
```svelte
|
||||
<!-- Button.svelte -->
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
const dispatch = createEventDispatcher();
|
||||
</script>
|
||||
|
||||
<button on:click={() => dispatch('click', { timestamp: Date.now() })}>Click</button>
|
||||
|
||||
<!-- Parent.svelte -->
|
||||
<Button on:click={(e) => console.log(e.detail)} />
|
||||
```
|
||||
|
||||
**Svelte 5:**
|
||||
```svelte
|
||||
<!-- Button.svelte -->
|
||||
<script>
|
||||
let { onclick } = $props();
|
||||
</script>
|
||||
|
||||
<button onclick={() => onclick?.({ timestamp: Date.now() })}>Click</button>
|
||||
|
||||
<!-- Parent.svelte -->
|
||||
<Button onclick={(data) => console.log(data)} />
|
||||
```
|
||||
|
||||
### Multiple Callbacks
|
||||
|
||||
```svelte
|
||||
<!-- Dialog.svelte -->
|
||||
<script>
|
||||
let { onconfirm, oncancel, onclose } = $props();
|
||||
</script>
|
||||
|
||||
<dialog>
|
||||
<button onclick={() => onconfirm?.()}>Confirm</button>
|
||||
<button onclick={() => oncancel?.()}>Cancel</button>
|
||||
<button onclick={() => onclose?.()}>X</button>
|
||||
</dialog>
|
||||
|
||||
<!-- Parent.svelte -->
|
||||
<Dialog
|
||||
onconfirm={() => save()}
|
||||
oncancel={() => reset()}
|
||||
onclose={() => visible = false}
|
||||
/>
|
||||
```
|
||||
|
||||
### Typed Callbacks with TypeScript
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
value?: string;
|
||||
onsearch?: (query: string) => void;
|
||||
onchange?: (value: string) => void;
|
||||
onclear?: () => void;
|
||||
}
|
||||
|
||||
let { value = '', onsearch, onchange, onclear }: Props = $props();
|
||||
|
||||
function handleInput(e: Event) {
|
||||
const newValue = (e.target as HTMLInputElement).value;
|
||||
value = newValue;
|
||||
onchange?.(newValue);
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### Forwarding Native Events
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import type { MouseEventHandler } from 'svelte/elements';
|
||||
|
||||
interface Props {
|
||||
onclick?: MouseEventHandler<HTMLButtonElement>;
|
||||
children: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
let { onclick, children, ...rest }: Props = $props();
|
||||
</script>
|
||||
|
||||
<button {onclick} {...rest}>{@render children()}</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Context API
|
||||
|
||||
Context functions must be called synchronously during component initialization.
|
||||
|
||||
### Correct Context Usage
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { setContext, getContext } from 'svelte';
|
||||
|
||||
// These run during initialization - CORRECT
|
||||
setContext('theme', 'dark');
|
||||
const theme = getContext('theme');
|
||||
|
||||
// INCORRECT - in $effect or callback
|
||||
// $effect(() => { setContext('theme', 'dark'); }); // ERROR
|
||||
</script>
|
||||
```
|
||||
|
||||
### Reactive Context Values
|
||||
|
||||
Pass `$state` objects for reactive context:
|
||||
|
||||
```svelte
|
||||
<!-- Provider.svelte -->
|
||||
<script>
|
||||
import { setContext } from 'svelte';
|
||||
|
||||
let theme = $state('light');
|
||||
|
||||
setContext('theme', {
|
||||
get current() { return theme; },
|
||||
toggle() { theme = theme === 'light' ? 'dark' : 'light'; }
|
||||
});
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
|
||||
<!-- Consumer.svelte -->
|
||||
<script>
|
||||
import { getContext } from 'svelte';
|
||||
const themeContext = getContext('theme');
|
||||
</script>
|
||||
|
||||
<p>Theme: {themeContext.current}</p>
|
||||
<button onclick={themeContext.toggle}>Toggle</button>
|
||||
```
|
||||
|
||||
### Type-Safe Context Pattern
|
||||
|
||||
```ts
|
||||
// context.ts
|
||||
import { setContext, getContext } from 'svelte';
|
||||
|
||||
const THEME_KEY = Symbol('theme');
|
||||
|
||||
interface ThemeContext {
|
||||
current: 'light' | 'dark';
|
||||
toggle: () => void;
|
||||
}
|
||||
|
||||
export function setThemeContext(context: ThemeContext) {
|
||||
setContext(THEME_KEY, context);
|
||||
}
|
||||
|
||||
export function getThemeContext(): ThemeContext {
|
||||
const context = getContext<ThemeContext>(THEME_KEY);
|
||||
if (!context) throw new Error('Theme context not found');
|
||||
return context;
|
||||
}
|
||||
```
|
||||
|
||||
### Avoid Generic Keys
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
// Option 1: Symbol
|
||||
const USER_KEY = Symbol('user');
|
||||
setContext(USER_KEY, userData);
|
||||
|
||||
// Option 2: Unique string
|
||||
setContext('myapp:user', userData);
|
||||
|
||||
// Option 3: Object key
|
||||
const userKey = {};
|
||||
setContext(userKey, userData);
|
||||
</script>
|
||||
```
|
||||
|
||||
### hasContext Check
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { hasContext, getContext } from 'svelte';
|
||||
|
||||
const hasTheme = hasContext('theme');
|
||||
const theme = hasTheme ? getContext('theme') : { current: 'light' };
|
||||
</script>
|
||||
```
|
||||
|
||||
### Context Boundaries
|
||||
|
||||
Context flows down the component tree:
|
||||
|
||||
```svelte
|
||||
<!-- Parent.svelte -->
|
||||
<script>
|
||||
import { getContext, setContext } from 'svelte';
|
||||
const level = getContext('level'); // 0
|
||||
setContext('level', level + 1); // Override for children
|
||||
</script>
|
||||
|
||||
<Child /> <!-- Will see level = 1 -->
|
||||
```
|
||||
@@ -0,0 +1,339 @@
|
||||
# Svelte 4 to Svelte 5 Migration Reference
|
||||
|
||||
## Table of Contents
|
||||
- [Migrating Reactive Statements](#migrating-reactive-statements)
|
||||
- [Migrating Stores to Runes](#migrating-stores-to-runes)
|
||||
|
||||
---
|
||||
|
||||
## Migrating Reactive Statements
|
||||
|
||||
Svelte 5 replaces `$:` with `$derived` (for values) and `$effect` (for side effects).
|
||||
|
||||
### Computed Values: $: to $derived
|
||||
|
||||
**Svelte 4:**
|
||||
```svelte
|
||||
<script>
|
||||
let count = 0;
|
||||
$: doubled = count * 2;
|
||||
$: quadrupled = doubled * 2;
|
||||
</script>
|
||||
```
|
||||
|
||||
**Svelte 5:**
|
||||
```svelte
|
||||
<script>
|
||||
let count = $state(0);
|
||||
let doubled = $derived(count * 2);
|
||||
let quadrupled = $derived(doubled * 2);
|
||||
</script>
|
||||
```
|
||||
|
||||
### Complex Computed Values: $derived.by
|
||||
|
||||
**Svelte 4:**
|
||||
```svelte
|
||||
<script>
|
||||
let items = [];
|
||||
let filter = 'all';
|
||||
|
||||
$: filteredItems = {
|
||||
if (filter === 'all') return items;
|
||||
if (filter === 'active') return items.filter(i => !i.done);
|
||||
return items.filter(i => i.done);
|
||||
};
|
||||
</script>
|
||||
```
|
||||
|
||||
**Svelte 5:**
|
||||
```svelte
|
||||
<script>
|
||||
let items = $state([]);
|
||||
let filter = $state('all');
|
||||
|
||||
let filteredItems = $derived.by(() => {
|
||||
if (filter === 'all') return items;
|
||||
if (filter === 'active') return items.filter(i => !i.done);
|
||||
return items.filter(i => i.done);
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Side Effects: $: to $effect
|
||||
|
||||
**Svelte 4:**
|
||||
```svelte
|
||||
<script>
|
||||
let count = 0;
|
||||
$: console.log('Count changed:', count);
|
||||
$: document.title = `Count: ${count}`;
|
||||
</script>
|
||||
```
|
||||
|
||||
**Svelte 5:**
|
||||
```svelte
|
||||
<script>
|
||||
let count = $state(0);
|
||||
|
||||
$effect(() => { console.log('Count changed:', count); });
|
||||
$effect(() => { document.title = `Count: ${count}`; });
|
||||
</script>
|
||||
```
|
||||
|
||||
### Conditional Side Effects
|
||||
|
||||
**Svelte 4:**
|
||||
```svelte
|
||||
<script>
|
||||
let value;
|
||||
$: if (value > 100) { alert('Value too high!'); }
|
||||
</script>
|
||||
```
|
||||
|
||||
**Svelte 5:**
|
||||
```svelte
|
||||
<script>
|
||||
let value = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
if (value > 100) { alert('Value too high!'); }
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Props Migration
|
||||
|
||||
**Svelte 4:**
|
||||
```svelte
|
||||
<script>
|
||||
export let name;
|
||||
export let count = 0;
|
||||
$: greeting = `Hello, ${name}!`;
|
||||
</script>
|
||||
```
|
||||
|
||||
**Svelte 5:**
|
||||
```svelte
|
||||
<script>
|
||||
let { name, count = 0 } = $props();
|
||||
let greeting = $derived(`Hello, ${name}!`);
|
||||
</script>
|
||||
```
|
||||
|
||||
### Effect with Cleanup
|
||||
|
||||
**Svelte 4:**
|
||||
```svelte
|
||||
<script>
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
let count = 0;
|
||||
let interval;
|
||||
|
||||
$: {
|
||||
clearInterval(interval);
|
||||
interval = setInterval(() => console.log(count), 1000);
|
||||
}
|
||||
|
||||
onDestroy(() => clearInterval(interval));
|
||||
</script>
|
||||
```
|
||||
|
||||
**Svelte 5:**
|
||||
```svelte
|
||||
<script>
|
||||
let count = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
const interval = setInterval(() => console.log(count), 1000);
|
||||
return () => clearInterval(interval); // Cleanup built-in
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Migration Cheat Sheet
|
||||
|
||||
| Svelte 4 Pattern | Svelte 5 Replacement |
|
||||
|------------------|----------------------|
|
||||
| `let x = 0` (in component) | `let x = $state(0)` |
|
||||
| `export let prop` | `let { prop } = $props()` |
|
||||
| `$: derived = expr` | `let derived = $derived(expr)` |
|
||||
| `$: { complex }` (value) | `let x = $derived.by(() => { ... })` |
|
||||
| `$: console.log(x)` | `$effect(() => console.log(x))` |
|
||||
| `$: if (x) { ... }` | `$effect(() => { if (x) { ... } })` |
|
||||
| `$: document.title = x` | `$effect(() => { document.title = x })` |
|
||||
|
||||
### Automated Migration
|
||||
|
||||
```bash
|
||||
npx sv migrate svelte-5
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migrating Stores to Runes
|
||||
|
||||
Svelte 5 runes can replace most store use cases with simpler, more direct reactive state.
|
||||
|
||||
### Local Component State
|
||||
|
||||
**Svelte 4:**
|
||||
```svelte
|
||||
<script>
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
const count = writable(0);
|
||||
function increment() { count.update(n => n + 1); }
|
||||
</script>
|
||||
|
||||
<button on:click={increment}>Count: {$count}</button>
|
||||
```
|
||||
|
||||
**Svelte 5:**
|
||||
```svelte
|
||||
<script>
|
||||
let count = $state(0);
|
||||
function increment() { count++; }
|
||||
</script>
|
||||
|
||||
<button onclick={increment}>Count: {count}</button>
|
||||
```
|
||||
|
||||
### Shared State Across Components
|
||||
|
||||
**Svelte 4 (stores.ts):**
|
||||
```ts
|
||||
import { writable } from 'svelte/store';
|
||||
export const user = writable(null);
|
||||
export const theme = writable('light');
|
||||
```
|
||||
|
||||
**Svelte 5 (state.svelte.ts):**
|
||||
```ts
|
||||
export const user = $state<User | null>(null);
|
||||
export const theme = $state({ current: 'light' as 'light' | 'dark' });
|
||||
|
||||
export function setTheme(newTheme: 'light' | 'dark') {
|
||||
theme.current = newTheme;
|
||||
}
|
||||
```
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { theme, setTheme } from './state.svelte';
|
||||
</script>
|
||||
|
||||
<p>Theme: {theme.current}</p>
|
||||
<button onclick={() => setTheme('dark')}>Dark Mode</button>
|
||||
```
|
||||
|
||||
### Derived Store to $derived
|
||||
|
||||
**Svelte 4:**
|
||||
```ts
|
||||
import { writable, derived } from 'svelte/store';
|
||||
export const items = writable([]);
|
||||
export const completedCount = derived(items, $items =>
|
||||
$items.filter(i => i.done).length
|
||||
);
|
||||
```
|
||||
|
||||
**Svelte 5:**
|
||||
```ts
|
||||
// state.svelte.ts
|
||||
export const items = $state<Item[]>([]);
|
||||
|
||||
export function getCompletedCount() {
|
||||
return items.filter(i => i.done).length;
|
||||
}
|
||||
```
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { items } from './state.svelte';
|
||||
let completedCount = $derived(items.filter(i => i.done).length);
|
||||
</script>
|
||||
```
|
||||
|
||||
### Custom Store to Reactive Class
|
||||
|
||||
**Svelte 4:**
|
||||
```ts
|
||||
function createCounter() {
|
||||
const { subscribe, set, update } = writable(0);
|
||||
return {
|
||||
subscribe,
|
||||
increment: () => update(n => n + 1),
|
||||
decrement: () => update(n => n - 1),
|
||||
reset: () => set(0)
|
||||
};
|
||||
}
|
||||
export const counter = createCounter();
|
||||
```
|
||||
|
||||
**Svelte 5:**
|
||||
```ts
|
||||
// counter.svelte.ts
|
||||
class Counter {
|
||||
value = $state(0);
|
||||
increment() { this.value++; }
|
||||
decrement() { this.value--; }
|
||||
reset() { this.value = 0; }
|
||||
}
|
||||
|
||||
export const counter = new Counter();
|
||||
```
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { counter } from './counter.svelte';
|
||||
</script>
|
||||
|
||||
<p>{counter.value}</p>
|
||||
<button onclick={() => counter.increment()}>+</button>
|
||||
```
|
||||
|
||||
### Async State Pattern
|
||||
|
||||
**Svelte 5:**
|
||||
```ts
|
||||
// api.svelte.ts
|
||||
export const state = $state({
|
||||
data: null as Data | null,
|
||||
loading: false,
|
||||
error: null as Error | null
|
||||
});
|
||||
|
||||
export async function fetchData() {
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/data');
|
||||
state.data = await res.json();
|
||||
} catch (e) {
|
||||
state.error = e as Error;
|
||||
} finally {
|
||||
state.loading = false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### When to Still Use Stores
|
||||
|
||||
1. **Library interop** - Libraries that expect Svelte stores
|
||||
2. **Legacy code** - Gradual migration
|
||||
3. **Observable patterns** - When you need the subscribe API
|
||||
4. **Server-side rendering** - Stores have built-in SSR handling
|
||||
|
||||
### Store to Rune Cheat Sheet
|
||||
|
||||
| Store Pattern | Rune Replacement |
|
||||
|---------------|------------------|
|
||||
| `writable(value)` | `$state(value)` |
|
||||
| `$store` (auto-subscribe) | Direct access to `$state` value |
|
||||
| `store.set(x)` | `state = x` |
|
||||
| `store.update(fn)` | Direct mutation or reassignment |
|
||||
| `derived(store, fn)` | `$derived(fn())` in component |
|
||||
| `readable(value, start)` | `$state` + `$effect` for setup |
|
||||
@@ -0,0 +1,443 @@
|
||||
# Svelte 5 Performance Reference
|
||||
|
||||
## Table of Contents
|
||||
- [Universal Reactivity](#universal-reactivity)
|
||||
- [Avoiding Over-Reactivity](#avoiding-over-reactivity)
|
||||
- [Preventing Load Waterfalls](#preventing-load-waterfalls)
|
||||
- [Streaming Non-Critical Data](#streaming-non-critical-data)
|
||||
- [Component Testing](#component-testing)
|
||||
|
||||
---
|
||||
|
||||
## Universal Reactivity
|
||||
|
||||
Svelte 5 allows runes in `.svelte.js` or `.svelte.ts` files for shared state outside components.
|
||||
|
||||
### Shared Counter State
|
||||
|
||||
```ts
|
||||
// counter.svelte.ts
|
||||
export const counter = $state({ count: 0 });
|
||||
|
||||
export function increment() { counter.count++; }
|
||||
export function decrement() { counter.count--; }
|
||||
export function reset() { counter.count = 0; }
|
||||
```
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { counter, increment } from './counter.svelte';
|
||||
</script>
|
||||
|
||||
<p>Count: {counter.count}</p>
|
||||
<button onclick={increment}>+</button>
|
||||
```
|
||||
|
||||
### Object State with Getters
|
||||
|
||||
```ts
|
||||
// user.svelte.ts
|
||||
const state = $state<User>({ firstName: '', lastName: '', email: '' });
|
||||
|
||||
export const user = {
|
||||
get firstName() { return state.firstName; },
|
||||
set firstName(v: string) { state.firstName = v; },
|
||||
get fullName() { return `${state.firstName} ${state.lastName}`; },
|
||||
get isValid() { return state.firstName && state.email.includes('@'); }
|
||||
};
|
||||
```
|
||||
|
||||
### Reactive Class Pattern
|
||||
|
||||
```ts
|
||||
// todo.svelte.ts
|
||||
class TodoStore {
|
||||
items = $state<Todo[]>([]);
|
||||
filter = $state<'all' | 'active' | 'completed'>('all');
|
||||
|
||||
get filtered() {
|
||||
switch (this.filter) {
|
||||
case 'active': return this.items.filter(t => !t.done);
|
||||
case 'completed': return this.items.filter(t => t.done);
|
||||
default: return this.items;
|
||||
}
|
||||
}
|
||||
|
||||
add(text: string) {
|
||||
this.items.push({ id: Date.now(), text, done: false });
|
||||
}
|
||||
}
|
||||
|
||||
export const todos = new TodoStore();
|
||||
```
|
||||
|
||||
### Important Notes
|
||||
|
||||
1. **File extension matters**: Must use `.svelte.js` or `.svelte.ts`
|
||||
2. **No $derived in module scope**: Use getters for derived values
|
||||
3. **SSR caution**: Avoid initializing browser-only state at module level
|
||||
|
||||
---
|
||||
|
||||
## Avoiding Over-Reactivity
|
||||
|
||||
Common mistakes with runes can cause unnecessary re-renders, infinite loops, or degraded performance.
|
||||
|
||||
### Anti-Pattern 1: Using $effect to Set Derived Values
|
||||
|
||||
**WRONG:**
|
||||
```svelte
|
||||
<script>
|
||||
let count = $state(0);
|
||||
let doubled = $state(0);
|
||||
$effect(() => { doubled = count * 2; }); // Anti-pattern!
|
||||
</script>
|
||||
```
|
||||
|
||||
**CORRECT:**
|
||||
```svelte
|
||||
<script>
|
||||
let count = $state(0);
|
||||
let doubled = $derived(count * 2);
|
||||
</script>
|
||||
```
|
||||
|
||||
### Anti-Pattern 2: Circular Dependencies
|
||||
|
||||
**WRONG:**
|
||||
```svelte
|
||||
<script>
|
||||
let a = $state(1);
|
||||
let b = $state(2);
|
||||
|
||||
$effect(() => {
|
||||
if (a > 10) b = 0;
|
||||
if (b > 10) a = 0; // Triggers the effect again!
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
**CORRECT: Use separate effects or event handlers**
|
||||
|
||||
### Anti-Pattern 3: Not Using untrack
|
||||
|
||||
**WRONG:**
|
||||
```svelte
|
||||
<script>
|
||||
let count = $state(0);
|
||||
let log = $state([]);
|
||||
|
||||
$effect(() => {
|
||||
log.push(`Count is ${count}`); // log change triggers re-run!
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
**CORRECT:**
|
||||
```svelte
|
||||
<script>
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
let count = $state(0);
|
||||
let log = $state([]);
|
||||
|
||||
$effect(() => {
|
||||
untrack(() => { log.push(`Count is ${count}`); });
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Anti-Pattern 4: Heavy Computations in $derived
|
||||
|
||||
**WRONG:**
|
||||
```svelte
|
||||
<script>
|
||||
let items = $state([/* thousands */]);
|
||||
let filter = $state('');
|
||||
|
||||
let filtered = $derived(
|
||||
items.filter(item => JSON.stringify(item).includes(filter))
|
||||
);
|
||||
</script>
|
||||
```
|
||||
|
||||
**CORRECT: Debounce expensive computations**
|
||||
```svelte
|
||||
<script>
|
||||
let filter = $state('');
|
||||
let debouncedFilter = $state('');
|
||||
|
||||
$effect(() => {
|
||||
const timeout = setTimeout(() => { debouncedFilter = filter; }, 300);
|
||||
return () => clearTimeout(timeout);
|
||||
});
|
||||
|
||||
let filtered = $derived(
|
||||
items.filter(item => item.name.includes(debouncedFilter))
|
||||
);
|
||||
</script>
|
||||
```
|
||||
|
||||
### Anti-Pattern 5: Effect for DOM Manipulation
|
||||
|
||||
**WRONG:**
|
||||
```svelte
|
||||
<script>
|
||||
let visible = $state(false);
|
||||
let element;
|
||||
|
||||
$effect(() => {
|
||||
if (element) element.style.display = visible ? 'block' : 'none';
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
**CORRECT:**
|
||||
```svelte
|
||||
{#if visible}<div>Content</div>{/if}
|
||||
<!-- Or -->
|
||||
<div class:hidden={!visible}>Content</div>
|
||||
```
|
||||
|
||||
### Performance Tips
|
||||
|
||||
1. Use `$derived` over `$effect` for computed values
|
||||
2. Debounce expensive reactive computations
|
||||
3. Use `untrack` to prevent unnecessary dependencies
|
||||
4. Group related state into objects
|
||||
5. Let Svelte handle DOM updates
|
||||
|
||||
---
|
||||
|
||||
## Preventing Load Waterfalls
|
||||
|
||||
Sequential API calls multiply latency. Use parallel requests and streaming.
|
||||
|
||||
### Anti-Pattern: Waterfall
|
||||
|
||||
**WRONG (3 seconds total):**
|
||||
```ts
|
||||
export const load = async ({ fetch }) => {
|
||||
const user = await fetch('/api/user').then(r => r.json()); // 1s
|
||||
const posts = await fetch(`/api/users/${user.id}/posts`).then(r => r.json()); // 1s
|
||||
const comments = await fetch('/api/comments').then(r => r.json()); // 1s
|
||||
return { user, posts, comments };
|
||||
};
|
||||
```
|
||||
|
||||
### Pattern 1: Parallel with Promise.all
|
||||
|
||||
**CORRECT (1 second total):**
|
||||
```ts
|
||||
export const load = async ({ fetch }) => {
|
||||
const [user, posts, comments] = await Promise.all([
|
||||
fetch('/api/user').then(r => r.json()),
|
||||
fetch('/api/posts').then(r => r.json()),
|
||||
fetch('/api/comments').then(r => r.json())
|
||||
]);
|
||||
return { user, posts, comments };
|
||||
};
|
||||
```
|
||||
|
||||
### Pattern 2: Partial Dependencies
|
||||
|
||||
```ts
|
||||
export const load = async ({ fetch }) => {
|
||||
const user = await fetch('/api/user').then(r => r.json());
|
||||
|
||||
const [posts, followers] = await Promise.all([
|
||||
fetch(`/api/users/${user.id}/posts`).then(r => r.json()),
|
||||
fetch(`/api/users/${user.id}/followers`).then(r => r.json())
|
||||
]);
|
||||
|
||||
return { user, posts, followers };
|
||||
};
|
||||
```
|
||||
|
||||
### Pattern 3: Streaming Non-Critical Data
|
||||
|
||||
```ts
|
||||
export const load = async ({ fetch }) => {
|
||||
const user = await fetch('/api/user').then(r => r.json());
|
||||
|
||||
return {
|
||||
user,
|
||||
recommendations: fetch('/api/recommendations').then(r => r.json()),
|
||||
analytics: fetch('/api/analytics').then(r => r.json())
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### Performance Comparison
|
||||
|
||||
| Pattern | 3 APIs x 1s each |
|
||||
|---------|------------------|
|
||||
| Sequential | 3 seconds |
|
||||
| Parallel (Promise.all) | 1 second |
|
||||
| Streaming | 0s initial, streams rest |
|
||||
|
||||
---
|
||||
|
||||
## Streaming Non-Critical Data
|
||||
|
||||
Return promises from load functions to stream non-essential data after initial page render.
|
||||
|
||||
### Basic Streaming
|
||||
|
||||
**WRONG (blocks on slow data):**
|
||||
```ts
|
||||
export const load = async ({ fetch }) => {
|
||||
const user = await fetch('/api/user').then(r => r.json()); // 100ms
|
||||
const analytics = await fetch('/api/analytics').then(r => r.json()); // 2000ms
|
||||
return { user, analytics }; // Page blocked for 2.1 seconds
|
||||
};
|
||||
```
|
||||
|
||||
**CORRECT (stream slow data):**
|
||||
```ts
|
||||
export const load = async ({ fetch }) => {
|
||||
const user = await fetch('/api/user').then(r => r.json());
|
||||
const analytics = fetch('/api/analytics').then(r => r.json()); // Don't await
|
||||
|
||||
return {
|
||||
user, // Available in 100ms
|
||||
analytics // Streams when ready
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### Component Handling
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import type { PageProps } from './$types';
|
||||
let { data }: PageProps = $props();
|
||||
</script>
|
||||
|
||||
<header><h1>Welcome, {data.user.name}</h1></header>
|
||||
|
||||
<aside>
|
||||
{#await data.analytics}
|
||||
<div class="skeleton">Loading analytics...</div>
|
||||
{:then analytics}
|
||||
<div class="analytics">
|
||||
<p>Page views: {analytics.views}</p>
|
||||
</div>
|
||||
{:catch}
|
||||
<div class="error">Failed to load analytics</div>
|
||||
{/await}
|
||||
</aside>
|
||||
```
|
||||
|
||||
### When to Stream
|
||||
|
||||
| Data Type | Stream? | Reason |
|
||||
|-----------|---------|--------|
|
||||
| User info | No | Critical for layout |
|
||||
| Main content | No | Users came for this |
|
||||
| Analytics | Yes | Not user-facing |
|
||||
| Recommendations | Yes | Supplementary |
|
||||
| Comments | Maybe | Important but can load later |
|
||||
|
||||
---
|
||||
|
||||
## Component Testing
|
||||
|
||||
Svelte 5 components with runes require proper Vitest configuration.
|
||||
|
||||
### Option 1: Vitest Browser Mode (Recommended)
|
||||
|
||||
```ts
|
||||
// vitest.config.ts
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
test: {
|
||||
browser: {
|
||||
enabled: true,
|
||||
provider: 'playwright',
|
||||
name: 'chromium'
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Option 2: JSDOM with @testing-library/svelte
|
||||
|
||||
```ts
|
||||
// vitest.config.ts
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte({ hot: !process.env.VITEST })],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
include: ['src/**/*.{test,spec}.{js,ts}'],
|
||||
globals: true
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Basic Component Test
|
||||
|
||||
```ts
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||
import { expect, test } from 'vitest';
|
||||
import Counter from './Counter.svelte';
|
||||
|
||||
test('increments count when clicked', async () => {
|
||||
render(Counter);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveTextContent('Count: 0');
|
||||
|
||||
await fireEvent.click(button);
|
||||
expect(button).toHaveTextContent('Count: 1');
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Props
|
||||
|
||||
```ts
|
||||
test('renders with custom name', () => {
|
||||
render(Greeting, { props: { name: 'Alice' } });
|
||||
expect(screen.getByText('Hello, Alice!')).toBeInTheDocument();
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Callbacks
|
||||
|
||||
```ts
|
||||
import { vi } from 'vitest';
|
||||
|
||||
test('calls onclick callback when clicked', async () => {
|
||||
const handleClick = vi.fn();
|
||||
render(Button, { props: { onclick: handleClick } });
|
||||
|
||||
await fireEvent.click(screen.getByRole('button'));
|
||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Async Components
|
||||
|
||||
```ts
|
||||
import { waitFor } from '@testing-library/svelte';
|
||||
|
||||
test('loads and displays user data', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ name: 'Alice' })
|
||||
});
|
||||
|
||||
render(UserProfile, { props: { userId: '123' } });
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Alice')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,394 @@
|
||||
# Svelte 5 Runes Reference
|
||||
|
||||
## Table of Contents
|
||||
- [$state - Reactive State](#state---reactive-state)
|
||||
- [$derived - Computed Values](#derived---computed-values)
|
||||
- [$effect - Side Effects](#effect---side-effects)
|
||||
- [$props - Component Props](#props---component-props)
|
||||
- [$bindable - Two-Way Binding](#bindable---two-way-binding)
|
||||
- [$inspect - Debugging](#inspect---debugging)
|
||||
|
||||
---
|
||||
|
||||
## $state - Reactive State
|
||||
|
||||
In Svelte 5, plain `let` declarations are no longer automatically reactive. Use `$state()` for reactive state.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let count = $state(0);
|
||||
</script>
|
||||
|
||||
<button onclick={() => count++}>
|
||||
Clicks: {count}
|
||||
</button>
|
||||
```
|
||||
|
||||
### Object and Array State
|
||||
|
||||
Objects and arrays are deeply reactive by default:
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let user = $state({ name: 'Alice', age: 30 });
|
||||
let items = $state(['apple', 'banana']);
|
||||
</script>
|
||||
|
||||
<button onclick={() => user.age++}>Age: {user.age}</button>
|
||||
<button onclick={() => items.push('cherry')}>Items: {items.length}</button>
|
||||
```
|
||||
|
||||
### Class State
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
class Counter {
|
||||
count = $state(0);
|
||||
increment() { this.count++; }
|
||||
}
|
||||
const counter = new Counter();
|
||||
</script>
|
||||
|
||||
<button onclick={() => counter.increment()}>{counter.count}</button>
|
||||
```
|
||||
|
||||
### Raw State (Opt-out of Deep Reactivity)
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let items = $state.raw([1, 2, 3]);
|
||||
|
||||
// This WON'T trigger an update:
|
||||
items.push(4);
|
||||
|
||||
// This WILL trigger an update:
|
||||
items = [...items, 4];
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## $derived - Computed Values
|
||||
|
||||
Replaces `$:` reactive statements for computed values.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let count = $state(0);
|
||||
let doubled = $derived(count * 2);
|
||||
let quadrupled = $derived(doubled * 2);
|
||||
</script>
|
||||
```
|
||||
|
||||
### Complex Derivations with $derived.by
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let items = $state([1, 2, 3, 4, 5]);
|
||||
let filter = $state('even');
|
||||
|
||||
let filteredItems = $derived.by(() => {
|
||||
if (filter === 'even') return items.filter(n => n % 2 === 0);
|
||||
return items.filter(n => n % 2 !== 0);
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Deriving from Props
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let { firstName, lastName } = $props();
|
||||
let fullName = $derived(`${firstName} ${lastName}`);
|
||||
</script>
|
||||
```
|
||||
|
||||
### $derived vs $effect
|
||||
|
||||
Use `$derived` for computing values, `$effect` for side effects:
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let count = $state(0);
|
||||
|
||||
// CORRECT: Use $derived for computed values
|
||||
let doubled = $derived(count * 2);
|
||||
|
||||
// INCORRECT: Don't use $effect to set derived values
|
||||
// let doubled;
|
||||
// $effect(() => { doubled = count * 2; }); // Anti-pattern!
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## $effect - Side Effects
|
||||
|
||||
Runs code when component mounts and when dependencies change. Requires cleanup for subscriptions.
|
||||
|
||||
### Basic Effect with Cleanup
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let count = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
const interval = setInterval(() => count++, 1000);
|
||||
return () => clearInterval(interval); // Cleanup
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### DOM Event Listeners
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let mouseX = $state(0);
|
||||
let mouseY = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
function handleMouseMove(event) {
|
||||
mouseX = event.clientX;
|
||||
mouseY = event.clientY;
|
||||
}
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
return () => window.removeEventListener('mousemove', handleMouseMove);
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### External Subscriptions
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let { userId } = $props();
|
||||
let userData = $state(null);
|
||||
|
||||
$effect(() => {
|
||||
const unsubscribe = database.subscribe(`users/${userId}`, (data) => {
|
||||
userData = data;
|
||||
});
|
||||
return () => unsubscribe();
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### $effect.pre for Pre-DOM Updates
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let div;
|
||||
let messages = $state([]);
|
||||
|
||||
$effect.pre(() => {
|
||||
if (div) {
|
||||
const shouldScroll = div.scrollTop + div.clientHeight >= div.scrollHeight - 20;
|
||||
if (shouldScroll) {
|
||||
tick().then(() => { div.scrollTop = div.scrollHeight; });
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Untracked Dependencies
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
let count = $state(0);
|
||||
let logCount = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
// Only runs when count changes, not logCount
|
||||
console.log(count, untrack(() => logCount));
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## $props - Component Props
|
||||
|
||||
Replaces `export let` for declaring component props.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let { name, count = 0, disabled = false } = $props();
|
||||
</script>
|
||||
```
|
||||
|
||||
### Rest Props Pattern
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let { class: className, children, ...restProps } = $props();
|
||||
</script>
|
||||
|
||||
<div class={className} {...restProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
```
|
||||
|
||||
### Renaming Reserved Words
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let {
|
||||
class: className, // 'class' is reserved
|
||||
for: htmlFor, // 'for' is reserved
|
||||
...rest
|
||||
} = $props();
|
||||
</script>
|
||||
```
|
||||
|
||||
### TypeScript Props
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
name: string;
|
||||
count?: number;
|
||||
onClick?: (event: MouseEvent) => void;
|
||||
}
|
||||
|
||||
let { name, count = 0, onClick }: Props = $props();
|
||||
</script>
|
||||
```
|
||||
|
||||
### Props with Children
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
children: Snippet;
|
||||
footer?: Snippet;
|
||||
}
|
||||
|
||||
let { title, children, footer }: Props = $props();
|
||||
</script>
|
||||
|
||||
<article>
|
||||
<h1>{title}</h1>
|
||||
<main>{@render children()}</main>
|
||||
{#if footer}<footer>{@render footer()}</footer>{/if}
|
||||
</article>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## $bindable - Two-Way Binding
|
||||
|
||||
Props must be explicitly marked with `$bindable()` to support `bind:`.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```svelte
|
||||
<!-- Input.svelte -->
|
||||
<script>
|
||||
let { value = $bindable() } = $props();
|
||||
</script>
|
||||
|
||||
<input bind:value />
|
||||
|
||||
<!-- Parent.svelte -->
|
||||
<script>
|
||||
import Input from './Input.svelte';
|
||||
let name = $state('');
|
||||
</script>
|
||||
|
||||
<Input bind:value={name} />
|
||||
```
|
||||
|
||||
### Bindable with Default Value
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let { value = $bindable('default') } = $props();
|
||||
</script>
|
||||
```
|
||||
|
||||
### Multiple Bindable Props
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let {
|
||||
value = $bindable(''),
|
||||
checked = $bindable(false),
|
||||
selected = $bindable(null)
|
||||
} = $props();
|
||||
</script>
|
||||
```
|
||||
|
||||
### TypeScript with Bindable
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { value = $bindable(''), disabled = false }: Props = $props();
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## $inspect - Debugging
|
||||
|
||||
Development-only debugging that logs values when they change. Stripped in production.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let count = $state(0);
|
||||
$inspect(count); // Logs every time count changes
|
||||
</script>
|
||||
```
|
||||
|
||||
### Multiple Values
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let name = $state('Alice');
|
||||
let age = $state(30);
|
||||
$inspect(name, age);
|
||||
</script>
|
||||
```
|
||||
|
||||
### Custom Logging with $inspect.with
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let user = $state({ name: 'Alice', age: 30 });
|
||||
|
||||
$inspect.with((type, ...values) => {
|
||||
if (type === 'init') console.log('Initial value:', values);
|
||||
else console.log('Updated to:', values);
|
||||
}, user);
|
||||
</script>
|
||||
```
|
||||
|
||||
### Debugging with Breakpoints
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let data = $state({ x: 0, y: 0 });
|
||||
|
||||
$inspect.with((type, value) => {
|
||||
if (type === 'update' && value.x > 100) debugger;
|
||||
}, data);
|
||||
</script>
|
||||
```
|
||||
@@ -0,0 +1,291 @@
|
||||
# Svelte 5 Snippets Reference
|
||||
|
||||
## Table of Contents
|
||||
- [Replacing Slots with Snippets](#replacing-slots-with-snippets)
|
||||
- [Rendering with @render](#rendering-with-render)
|
||||
|
||||
---
|
||||
|
||||
## Replacing Slots with Snippets
|
||||
|
||||
Svelte 5 replaces `<slot>` with snippets - a more powerful and type-safe composition primitive.
|
||||
|
||||
### Default Content (children)
|
||||
|
||||
**Svelte 4:**
|
||||
```svelte
|
||||
<div class="card"><slot /></div>
|
||||
```
|
||||
|
||||
**Svelte 5:**
|
||||
```svelte
|
||||
<script>
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<div class="card">{@render children?.()}</div>
|
||||
```
|
||||
|
||||
### Named Slots to Named Snippets
|
||||
|
||||
**Svelte 4:**
|
||||
```svelte
|
||||
<header><slot name="header" /></header>
|
||||
<main><slot /></main>
|
||||
<footer><slot name="footer" /></footer>
|
||||
|
||||
<!-- Usage -->
|
||||
<Layout>
|
||||
<h1 slot="header">Title</h1>
|
||||
<p>Content</p>
|
||||
<span slot="footer">Footer</span>
|
||||
</Layout>
|
||||
```
|
||||
|
||||
**Svelte 5:**
|
||||
```svelte
|
||||
<script>
|
||||
let { header, children, footer } = $props();
|
||||
</script>
|
||||
|
||||
<header>{@render header?.()}</header>
|
||||
<main>{@render children?.()}</main>
|
||||
<footer>{@render footer?.()}</footer>
|
||||
|
||||
<!-- Usage -->
|
||||
<Layout>
|
||||
{#snippet header()}<h1>Title</h1>{/snippet}
|
||||
<p>Content</p>
|
||||
{#snippet footer()}<span>Footer</span>{/snippet}
|
||||
</Layout>
|
||||
```
|
||||
|
||||
### Slot Props to Snippet Parameters
|
||||
|
||||
**Svelte 4:**
|
||||
```svelte
|
||||
<ul>
|
||||
{#each items as item}
|
||||
<li><slot {item} /></li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<List {items} let:item>
|
||||
<span>{item.name}</span>
|
||||
</List>
|
||||
```
|
||||
|
||||
**Svelte 5:**
|
||||
```svelte
|
||||
<script>
|
||||
let { items, children } = $props();
|
||||
</script>
|
||||
|
||||
<ul>
|
||||
{#each items as item}
|
||||
<li>{@render children?.(item)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<List {items}>
|
||||
{#snippet children(item)}
|
||||
<span>{item.name}</span>
|
||||
{/snippet}
|
||||
</List>
|
||||
```
|
||||
|
||||
### Slot Fallback to Snippet Fallback
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{:else}
|
||||
<p>Default content</p>
|
||||
{/if}
|
||||
```
|
||||
|
||||
### Defining Snippets Within Components
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let items = $state([
|
||||
{ id: 1, name: 'Item 1', type: 'normal' },
|
||||
{ id: 2, name: 'Item 2', type: 'featured' }
|
||||
]);
|
||||
</script>
|
||||
|
||||
{#snippet normalItem(item)}
|
||||
<li>{item.name}</li>
|
||||
{/snippet}
|
||||
|
||||
{#snippet featuredItem(item)}
|
||||
<li class="featured">*** {item.name} ***</li>
|
||||
{/snippet}
|
||||
|
||||
<ul>
|
||||
{#each items as item}
|
||||
{#if item.type === 'featured'}
|
||||
{@render featuredItem(item)}
|
||||
{:else}
|
||||
{@render normalItem(item)}
|
||||
{/if}
|
||||
{/each}
|
||||
</ul>
|
||||
```
|
||||
|
||||
### TypeScript Typing
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Item { id: number; name: string; }
|
||||
|
||||
interface Props {
|
||||
header?: Snippet;
|
||||
children: Snippet<[item: Item]>;
|
||||
footer?: Snippet;
|
||||
}
|
||||
|
||||
let { header, children, footer }: Props = $props();
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rendering with @render
|
||||
|
||||
Snippets must be rendered using `{@render}`. Optional snippets need null-safe calling.
|
||||
|
||||
### Basic Snippet Rendering
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<div>{@render children()}</div>
|
||||
```
|
||||
|
||||
### Optional Snippets
|
||||
|
||||
Always use optional chaining for snippets that might not be provided:
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{@render children?.()}
|
||||
```
|
||||
|
||||
### Conditional Rendering
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let { header, children, footer } = $props();
|
||||
</script>
|
||||
|
||||
{#if header}
|
||||
<header>{@render header()}</header>
|
||||
{/if}
|
||||
|
||||
<main>{@render children?.()}</main>
|
||||
|
||||
{#if footer}
|
||||
<footer>{@render footer()}</footer>
|
||||
{/if}
|
||||
```
|
||||
|
||||
### Rendering with Arguments
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let { items, itemTemplate } = $props();
|
||||
</script>
|
||||
|
||||
<ul>
|
||||
{#each items as item, index}
|
||||
<li>{@render itemTemplate(item, index)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<!-- Usage -->
|
||||
<List {items}>
|
||||
{#snippet itemTemplate(item, index)}
|
||||
<span>{index + 1}. {item.name}</span>
|
||||
{/snippet}
|
||||
</List>
|
||||
```
|
||||
|
||||
### Rendering Multiple Times
|
||||
|
||||
Snippets can be rendered multiple times:
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let { icon } = $props();
|
||||
</script>
|
||||
|
||||
<button>
|
||||
{@render icon?.()}
|
||||
<span>Click me</span>
|
||||
{@render icon?.()}
|
||||
</button>
|
||||
```
|
||||
|
||||
### Rendering Dynamic Snippets
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let { normalView, editView, isEditing } = $props();
|
||||
let currentView = $derived(isEditing ? editView : normalView);
|
||||
</script>
|
||||
|
||||
{@render currentView?.()}
|
||||
```
|
||||
|
||||
### TypeScript with @render
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
header?: Snippet;
|
||||
row: Snippet<[data: { id: number; name: string }]>;
|
||||
}
|
||||
|
||||
let { children, header, row }: Props = $props();
|
||||
</script>
|
||||
|
||||
{@render header?.()}
|
||||
{@render children()}
|
||||
{@render row({ id: 1, name: 'Test' })}
|
||||
```
|
||||
|
||||
### Passing Snippets to Child Components
|
||||
|
||||
```svelte
|
||||
<!-- Wrapper.svelte -->
|
||||
<script>
|
||||
import Inner from './Inner.svelte';
|
||||
let { itemRenderer } = $props();
|
||||
</script>
|
||||
|
||||
<Inner {itemRenderer} />
|
||||
|
||||
<!-- Inner.svelte -->
|
||||
<script>
|
||||
let { itemRenderer } = $props();
|
||||
</script>
|
||||
|
||||
{#each items as item}
|
||||
{@render itemRenderer?.(item)}
|
||||
{/each}
|
||||
```
|
||||
@@ -0,0 +1,401 @@
|
||||
# SvelteKit Patterns Reference
|
||||
|
||||
## Table of Contents
|
||||
- [Load Function Types](#load-function-types)
|
||||
- [Page Props Typing](#page-props-typing)
|
||||
- [Form Actions Error Handling](#form-actions-error-handling)
|
||||
- [SSR State Isolation](#ssr-state-isolation)
|
||||
|
||||
---
|
||||
|
||||
## Load Function Types
|
||||
|
||||
SvelteKit has two load function types: universal (`+page.js`) and server-only (`+page.server.ts`).
|
||||
|
||||
### When to Use Each
|
||||
|
||||
| Use +page.server.ts | Use +page.js |
|
||||
|---------------------|--------------|
|
||||
| Accessing secrets/credentials | Public APIs only |
|
||||
| Database connections | Non-serializable data (functions, classes) |
|
||||
| Server-only APIs | Client-side caching benefits |
|
||||
| Sensitive business logic | |
|
||||
|
||||
### Security: Secrets in Server Load
|
||||
|
||||
**WRONG (secrets exposed):**
|
||||
```ts
|
||||
// +page.js - DANGEROUS: runs in browser!
|
||||
export const load = async ({ fetch }) => {
|
||||
const response = await fetch('https://api.stripe.com/charges', {
|
||||
headers: { 'Authorization': `Bearer ${STRIPE_SECRET_KEY}` } // EXPOSED!
|
||||
});
|
||||
return { charges: await response.json() };
|
||||
};
|
||||
```
|
||||
|
||||
**CORRECT:**
|
||||
```ts
|
||||
// +page.server.ts - only runs on server
|
||||
import { STRIPE_SECRET_KEY } from '$env/static/private';
|
||||
|
||||
export const load = async ({ fetch }) => {
|
||||
const response = await fetch('https://api.stripe.com/charges', {
|
||||
headers: { 'Authorization': `Bearer ${STRIPE_SECRET_KEY}` }
|
||||
});
|
||||
return { charges: await response.json() };
|
||||
};
|
||||
```
|
||||
|
||||
### Serialization: Non-serializable in Universal Load
|
||||
|
||||
**WRONG (server load can't serialize functions):**
|
||||
```ts
|
||||
// +page.server.ts
|
||||
export const load = async () => {
|
||||
return {
|
||||
formatDate: (date: Date) => date.toLocaleDateString(), // ERROR
|
||||
parser: new DOMParser() // ERROR
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
**CORRECT:**
|
||||
```ts
|
||||
// +page.js
|
||||
export const load = async () => {
|
||||
return {
|
||||
formatDate: (date: Date) => date.toLocaleDateString(), // OK
|
||||
parser: typeof window !== 'undefined' ? new DOMParser() : null
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### Database Access
|
||||
|
||||
```ts
|
||||
// +page.server.ts - CORRECT
|
||||
import { db } from '$lib/server/database';
|
||||
|
||||
export const load = async () => {
|
||||
const users = await db.query('SELECT * FROM users');
|
||||
return { users };
|
||||
};
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```ts
|
||||
// +page.server.ts - Private env vars
|
||||
import { DATABASE_URL, API_SECRET } from '$env/static/private';
|
||||
|
||||
// +page.js - Only public env vars
|
||||
import { PUBLIC_API_URL } from '$env/static/public';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Page Props Typing
|
||||
|
||||
SvelteKit generates types in `./$types` for full type safety with `$props()`.
|
||||
|
||||
### Basic Page Data Typing
|
||||
|
||||
```svelte
|
||||
<!-- +page.svelte -->
|
||||
<script lang="ts">
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
</script>
|
||||
|
||||
<h1>{data.title}</h1>
|
||||
```
|
||||
|
||||
### Page Data with Form Actions
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data, form }: PageProps = $props();
|
||||
</script>
|
||||
|
||||
{#if form?.success}
|
||||
<p class="success">{form.message}</p>
|
||||
{/if}
|
||||
|
||||
{#if form?.error}
|
||||
<p class="error">{form.error}</p>
|
||||
{/if}
|
||||
```
|
||||
|
||||
### Layout Data Typing
|
||||
|
||||
```svelte
|
||||
<!-- +layout.svelte -->
|
||||
<script lang="ts">
|
||||
import type { LayoutProps } from './$types';
|
||||
|
||||
let { data, children }: LayoutProps = $props();
|
||||
</script>
|
||||
|
||||
<nav>
|
||||
{#if data.user}<span>Welcome, {data.user.name}</span>{/if}
|
||||
</nav>
|
||||
|
||||
{@render children()}
|
||||
```
|
||||
|
||||
### Corresponding Load Function
|
||||
|
||||
```ts
|
||||
// +page.server.ts
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
return {
|
||||
title: 'My Page',
|
||||
items: await fetchItems()
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request }) => {
|
||||
return { success: true, message: 'Saved successfully' };
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Error Page
|
||||
|
||||
```svelte
|
||||
<!-- +error.svelte -->
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
</script>
|
||||
|
||||
<h1>{page.status}: {page.error?.message}</h1>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Form Actions Error Handling
|
||||
|
||||
SvelteKit distinguishes between validation errors (`fail()`) and unexpected errors (`throw error()`).
|
||||
|
||||
### Use fail() for Validation Errors
|
||||
|
||||
**WRONG:**
|
||||
```ts
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
export const actions = {
|
||||
default: async ({ request }) => {
|
||||
if (!email?.toString().includes('@')) {
|
||||
throw error(400, 'Invalid email'); // Shows error page!
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**CORRECT:**
|
||||
```ts
|
||||
import { fail } from '@sveltejs/kit';
|
||||
|
||||
export const actions = {
|
||||
default: async ({ request }) => {
|
||||
const data = await request.formData();
|
||||
const email = data.get('email')?.toString() ?? '';
|
||||
|
||||
if (!email.includes('@')) {
|
||||
return fail(400, {
|
||||
error: 'Invalid email address',
|
||||
email // Return for repopulation
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Use throw error() for Unexpected Errors
|
||||
|
||||
```ts
|
||||
import { fail, error } from '@sveltejs/kit';
|
||||
|
||||
export const actions = {
|
||||
default: async ({ request }) => {
|
||||
const data = await request.formData();
|
||||
|
||||
// Validation - use fail()
|
||||
if (!data.get('title')) {
|
||||
return fail(400, { error: 'Title is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
await database.save(data);
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
// Unexpected error - use throw
|
||||
throw error(500, 'Unable to save. Please try again later.');
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Structured Validation Responses
|
||||
|
||||
```ts
|
||||
interface FormErrors {
|
||||
email?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
register: async ({ request }) => {
|
||||
const data = await request.formData();
|
||||
const errors: FormErrors = {};
|
||||
|
||||
if (!email) errors.email = 'Email is required';
|
||||
if (password.length < 8) errors.password = 'Password must be at least 8 characters';
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
return fail(400, { errors, values: { email } });
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Handle in Component
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import type { PageProps } from './$types';
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
let { form }: PageProps = $props();
|
||||
</script>
|
||||
|
||||
<form method="POST" action="?/register" use:enhance>
|
||||
<label>
|
||||
Email
|
||||
<input name="email" value={form?.values?.email ?? ''} />
|
||||
{#if form?.errors?.email}
|
||||
<span class="error">{form.errors.email}</span>
|
||||
{/if}
|
||||
</label>
|
||||
<button type="submit">Register</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
### Error Response Summary
|
||||
|
||||
| Situation | Function | Result |
|
||||
|-----------|----------|--------|
|
||||
| Missing field | `fail(400, {...})` | Form state preserved |
|
||||
| Invalid format | `fail(400, {...})` | Form state preserved |
|
||||
| Not found | `throw error(404, ...)` | Error page |
|
||||
| Server crash | `throw error(500, ...)` | Error page |
|
||||
| Auth required | `throw redirect(303, ...)` | Redirect |
|
||||
|
||||
---
|
||||
|
||||
## SSR State Isolation
|
||||
|
||||
Shared server state persists across requests, potentially leaking data between users.
|
||||
|
||||
### Dangerous: Module-Level State
|
||||
|
||||
**WRONG:**
|
||||
```ts
|
||||
// +page.server.ts
|
||||
let currentUser = null; // SHARED ACROSS ALL REQUESTS!
|
||||
|
||||
export const load = async ({ locals }) => {
|
||||
currentUser = locals.user; // User B overwrites User A
|
||||
return { user: currentUser };
|
||||
};
|
||||
```
|
||||
|
||||
**CORRECT:**
|
||||
```ts
|
||||
export const load = async ({ locals }) => {
|
||||
return { user: locals.user }; // Each request gets its own locals
|
||||
};
|
||||
```
|
||||
|
||||
### Dangerous: Global Stores
|
||||
|
||||
**WRONG:**
|
||||
```ts
|
||||
// stores.svelte.ts
|
||||
export const user = $state<User | null>(null); // Server-side singleton!
|
||||
```
|
||||
|
||||
**CORRECT: Use locals and return data from load**
|
||||
```ts
|
||||
// +layout.server.ts
|
||||
export const load = async ({ locals }) => {
|
||||
return { user: locals.user };
|
||||
};
|
||||
```
|
||||
|
||||
### Safe Patterns
|
||||
|
||||
**Pattern 1: Use locals**
|
||||
```ts
|
||||
// hooks.server.ts
|
||||
export const handle = async ({ event, resolve }) => {
|
||||
event.locals.user = await authenticate(event);
|
||||
event.locals.requestId = crypto.randomUUID();
|
||||
return resolve(event);
|
||||
};
|
||||
|
||||
// +page.server.ts
|
||||
export const load = async ({ locals }) => {
|
||||
return { user: locals.user };
|
||||
};
|
||||
```
|
||||
|
||||
**Pattern 2: Context for Component Trees**
|
||||
```svelte
|
||||
<!-- +layout.svelte -->
|
||||
<script lang="ts">
|
||||
import { setContext } from 'svelte';
|
||||
import type { LayoutProps } from './$types';
|
||||
|
||||
let { data, children }: LayoutProps = $props();
|
||||
|
||||
setContext('user', {
|
||||
get current() { return data.user; }
|
||||
});
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
```
|
||||
|
||||
**Pattern 3: Client-Only State**
|
||||
```ts
|
||||
// stores.svelte.ts
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
function createClientStore() {
|
||||
if (!browser) return { value: null };
|
||||
const state = $state({ value: null });
|
||||
return state;
|
||||
}
|
||||
|
||||
export const clientState = createClientStore();
|
||||
```
|
||||
|
||||
### SSR Safety Checklist
|
||||
|
||||
- [ ] No module-level `let` variables that store user data
|
||||
- [ ] No global `$state` that gets set during SSR
|
||||
- [ ] No singleton service classes with mutable state
|
||||
- [ ] All user-specific data flows through `locals`
|
||||
- [ ] All page data comes from load function returns
|
||||
@@ -0,0 +1,305 @@
|
||||
# Svelte 5 TypeScript Reference
|
||||
|
||||
## Table of Contents
|
||||
- [Props Typing](#props-typing)
|
||||
- [Generic Components](#generic-components)
|
||||
|
||||
---
|
||||
|
||||
## Props Typing
|
||||
|
||||
`$props()` requires specific patterns for TypeScript typing.
|
||||
|
||||
### Basic Props Typing
|
||||
|
||||
**Inline typing:**
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
let { name, count = 0 }: { name: string; count?: number } = $props();
|
||||
</script>
|
||||
```
|
||||
|
||||
**Interface typing (recommended):**
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
name: string;
|
||||
count?: number;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { name, count = 0, disabled = false }: Props = $props();
|
||||
</script>
|
||||
```
|
||||
|
||||
### Children and Snippets Typing
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
children: Snippet; // Required children
|
||||
header?: Snippet; // Optional snippet
|
||||
row: Snippet<[data: RowData]>; // Snippet with parameter
|
||||
cell?: Snippet<[value: string, index: number]>; // Multiple params
|
||||
}
|
||||
|
||||
interface RowData { id: number; name: string; }
|
||||
|
||||
let { children, header, row, cell }: Props = $props();
|
||||
</script>
|
||||
```
|
||||
|
||||
### Callback Props Typing
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
value: string;
|
||||
onchange?: (value: string) => void;
|
||||
onsubmit?: (data: FormData) => Promise<void>;
|
||||
onclick?: (event: MouseEvent) => void;
|
||||
}
|
||||
|
||||
let { value, onchange, onsubmit, onclick }: Props = $props();
|
||||
</script>
|
||||
```
|
||||
|
||||
### Rest Props with HTML Attributes
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import type { HTMLButtonAttributes } from 'svelte/elements';
|
||||
|
||||
interface Props extends HTMLButtonAttributes {
|
||||
variant?: 'primary' | 'secondary';
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
let { variant = 'primary', loading = false, ...rest }: Props = $props();
|
||||
</script>
|
||||
|
||||
<button class={variant} disabled={loading} {...rest}>
|
||||
{#if loading}Loading...{:else}{@render children?.()}{/if}
|
||||
</button>
|
||||
```
|
||||
|
||||
### Input Element Props
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import type { HTMLInputAttributes } from 'svelte/elements';
|
||||
|
||||
interface Props extends Omit<HTMLInputAttributes, 'value'> {
|
||||
value?: string;
|
||||
label: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
let { value = $bindable(''), label, error, ...rest }: Props = $props();
|
||||
</script>
|
||||
```
|
||||
|
||||
### Union Type Props
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
type ButtonVariant = 'primary' | 'secondary' | 'danger';
|
||||
type ButtonSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
interface Props {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { variant = 'primary', size = 'md', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<button class="{variant} {size}">{@render children()}</button>
|
||||
```
|
||||
|
||||
### Discriminated Union Props
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
type Props =
|
||||
| { type: 'link'; href: string; children: Snippet }
|
||||
| { type: 'button'; onclick: () => void; children: Snippet };
|
||||
|
||||
let props: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if props.type === 'link'}
|
||||
<a href={props.href}>{@render props.children()}</a>
|
||||
{:else}
|
||||
<button onclick={props.onclick}>{@render props.children()}</button>
|
||||
{/if}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Generic Components
|
||||
|
||||
Svelte 5 uses the `generics` attribute for type-safe reusable components.
|
||||
|
||||
### Basic Generic Component
|
||||
|
||||
```svelte
|
||||
<!-- List.svelte -->
|
||||
<script lang="ts" generics="T">
|
||||
interface Props {
|
||||
items: T[];
|
||||
children: import('svelte').Snippet<[item: T, index: number]>;
|
||||
}
|
||||
|
||||
let { items, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<ul>
|
||||
{#each items as item, index}
|
||||
<li>{@render children(item, index)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<!-- Usage -->
|
||||
<script lang="ts">
|
||||
import List from './List.svelte';
|
||||
|
||||
interface User { id: number; name: string; }
|
||||
let users: User[] = $state([{ id: 1, name: 'Alice' }]);
|
||||
</script>
|
||||
|
||||
<List items={users}>
|
||||
{#snippet children(user, index)}
|
||||
<span>{index + 1}. {user.name}</span> <!-- Fully typed! -->
|
||||
{/snippet}
|
||||
</List>
|
||||
```
|
||||
|
||||
### Multiple Type Parameters
|
||||
|
||||
```svelte
|
||||
<script lang="ts" generics="K, V">
|
||||
interface Props {
|
||||
entries: [K, V][];
|
||||
renderKey: import('svelte').Snippet<[key: K]>;
|
||||
renderValue: import('svelte').Snippet<[value: V]>;
|
||||
}
|
||||
|
||||
let { entries, renderKey, renderValue }: Props = $props();
|
||||
</script>
|
||||
|
||||
<dl>
|
||||
{#each entries as [key, value]}
|
||||
<dt>{@render renderKey(key)}</dt>
|
||||
<dd>{@render renderValue(value)}</dd>
|
||||
{/each}
|
||||
</dl>
|
||||
```
|
||||
|
||||
### Constrained Generics
|
||||
|
||||
```svelte
|
||||
<script lang="ts" generics="T extends { id: string | number }">
|
||||
interface Props {
|
||||
items: T[];
|
||||
selected?: T;
|
||||
onselect?: (item: T) => void;
|
||||
children: import('svelte').Snippet<[item: T, isSelected: boolean]>;
|
||||
}
|
||||
|
||||
let { items, selected, onselect, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#each items as item}
|
||||
<div
|
||||
class:selected={selected?.id === item.id}
|
||||
onclick={() => onselect?.(item)}
|
||||
>
|
||||
{@render children(item, selected?.id === item.id)}
|
||||
</div>
|
||||
{/each}
|
||||
```
|
||||
|
||||
### Generic with Default Type
|
||||
|
||||
```svelte
|
||||
<script lang="ts" generics="T = Record<string, unknown>">
|
||||
interface Props {
|
||||
data: T[];
|
||||
columns: (keyof T)[];
|
||||
}
|
||||
|
||||
let { data, columns }: Props = $props();
|
||||
</script>
|
||||
```
|
||||
|
||||
### Generic Select Component
|
||||
|
||||
```svelte
|
||||
<script lang="ts" generics="T">
|
||||
interface Props {
|
||||
options: T[];
|
||||
value?: T;
|
||||
getLabel: (option: T) => string;
|
||||
getValue: (option: T) => string;
|
||||
onchange?: (selected: T | undefined) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
options,
|
||||
value = $bindable(),
|
||||
getLabel,
|
||||
getValue,
|
||||
onchange,
|
||||
placeholder = 'Select...'
|
||||
}: Props = $props();
|
||||
|
||||
function handleChange(e: Event) {
|
||||
const selectedValue = (e.target as HTMLSelectElement).value;
|
||||
const selected = options.find(o => getValue(o) === selectedValue);
|
||||
value = selected;
|
||||
onchange?.(selected);
|
||||
}
|
||||
</script>
|
||||
|
||||
<select onchange={handleChange}>
|
||||
<option value="">{placeholder}</option>
|
||||
{#each options as option}
|
||||
<option
|
||||
value={getValue(option)}
|
||||
selected={value && getValue(value) === getValue(option)}
|
||||
>
|
||||
{getLabel(option)}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
```
|
||||
|
||||
### Generic Async Component
|
||||
|
||||
```svelte
|
||||
<script lang="ts" generics="T">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
promise: Promise<T>;
|
||||
loading?: Snippet;
|
||||
error?: Snippet<[error: Error]>;
|
||||
children: Snippet<[data: T]>;
|
||||
}
|
||||
|
||||
let { promise, loading, error, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#await promise}
|
||||
{#if loading}{@render loading()}{:else}<p>Loading...</p>{/if}
|
||||
{:then data}
|
||||
{@render children(data)}
|
||||
{:catch err}
|
||||
{#if error}{@render error(err)}{:else}<p>Error: {err.message}</p>{/if}
|
||||
{/await}
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# Sveltekit Data Flow
|
||||
|
||||
SvelteKit data flow guidance. Use for load functions, form actions,
|
||||
and server/client data. Covers +page.server.ts vs +page.ts,
|
||||
serialization, fail(), redirect(), error().
|
||||
|
||||
## Structure
|
||||
|
||||
- `SKILL.md` - Main skill instructions
|
||||
- `references/` - Detailed documentation loaded as needed
|
||||
- `scripts/` - Executable code for deterministic operations
|
||||
- `assets/` - Templates, images, or other resources
|
||||
|
||||
## Usage
|
||||
|
||||
This skill is automatically discovered by Claude when relevant to the
|
||||
task.
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
name: sveltekit-data-flow
|
||||
# IMPORTANT: Keep description on ONE line for Claude Code compatibility
|
||||
# prettier-ignore
|
||||
description: SvelteKit data flow guidance. Use for load functions, form actions, server/client data, and invalidation. Covers +page.server.ts vs +page.ts, serialization, fail(), redirect(), error(), invalidateAll().
|
||||
---
|
||||
|
||||
# SvelteKit Data Flow
|
||||
|
||||
## Quick Start
|
||||
|
||||
**Which file?** Server-only (DB/secrets): `+page.server.ts` |
|
||||
Universal (runs both): `+page.ts` | API: `+server.ts`
|
||||
|
||||
**Load decision:** Need server resources? → server load | Need client
|
||||
APIs? → universal load
|
||||
|
||||
**Form actions:** Always `+page.server.ts`. Return `fail()` for
|
||||
errors, throw `redirect()` to navigate, throw `error()` for failures.
|
||||
|
||||
## Example
|
||||
|
||||
```typescript
|
||||
// +page.server.ts
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
|
||||
export const load = async ({ locals }) => {
|
||||
const user = await db.users.get(locals.userId);
|
||||
return { user }; // Must be JSON-serializable
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
default: async ({ request }) => {
|
||||
const data = await request.formData();
|
||||
const email = data.get('email');
|
||||
|
||||
if (!email) return fail(400, { email, missing: true });
|
||||
|
||||
await updateEmail(email);
|
||||
throw redirect(303, '/success');
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Reference Files
|
||||
|
||||
- [load-functions.md](references/load-functions.md) - Server vs
|
||||
universal
|
||||
- [form-actions.md](references/form-actions.md) - Form handling
|
||||
patterns
|
||||
- [serialization.md](references/serialization.md) - What can/can't
|
||||
serialize
|
||||
- [error-redirect-handling.md](references/error-redirect-handling.md) -
|
||||
fail/redirect/error
|
||||
- [client-auth-invalidation.md](references/client-auth-invalidation.md) -
|
||||
invalidateAll() after client-side auth
|
||||
|
||||
## Notes
|
||||
|
||||
- Server load → universal load via `data` param | ALWAYS
|
||||
`throw redirect()/error()`
|
||||
- No class instances/functions from server load (not serializable)
|
||||
- **Last verified:** 2025-01-11
|
||||
|
||||
<!--
|
||||
PROGRESSIVE DISCLOSURE GUIDELINES:
|
||||
- Keep this file ~50 lines total (max ~150 lines)
|
||||
- Use 1-2 code blocks only (recommend 1)
|
||||
- Keep description <200 chars for Level 1 efficiency
|
||||
- Move detailed docs to references/ for Level 3 loading
|
||||
- This is Level 2 - quick reference ONLY, not a manual
|
||||
|
||||
LLM WORKFLOW (when editing this file):
|
||||
1. Write/edit SKILL.md
|
||||
2. Format (if formatter available)
|
||||
3. Run: claude-skills-cli validate <path>
|
||||
4. If multi-line description warning: run claude-skills-cli doctor <path>
|
||||
5. Validate again to confirm
|
||||
-->
|
||||
@@ -0,0 +1,125 @@
|
||||
# Client-Side Auth Invalidation
|
||||
|
||||
When using client-side auth libraries (Better Auth, Firebase, Supabase client),
|
||||
layout server data doesn't auto-refresh after login/logout.
|
||||
|
||||
## The Problem
|
||||
|
||||
```typescript
|
||||
// signin/+page.svelte - BROKEN
|
||||
async function handle_signin() {
|
||||
await auth_client.signIn.email({ email, password });
|
||||
goto('/'); // Layout still shows "logged out"
|
||||
}
|
||||
```
|
||||
|
||||
**Why?** Client-side navigation (`goto()`) doesn't re-run server load functions.
|
||||
The session cookie is set, but `+layout.server.ts` data is stale.
|
||||
|
||||
## Solution A: Inline Invalidation (Simple)
|
||||
|
||||
Call `invalidateAll()` after auth state change:
|
||||
|
||||
```typescript
|
||||
// signin/+page.svelte
|
||||
import { goto, invalidateAll } from '$app/navigation';
|
||||
|
||||
async function handle_signin() {
|
||||
const result = await auth_client.signIn.email({ email, password });
|
||||
if (result.error) return;
|
||||
|
||||
await invalidateAll(); // Re-runs ALL load functions
|
||||
goto('/');
|
||||
}
|
||||
|
||||
// signout handler
|
||||
async function handle_signout() {
|
||||
await auth_client.signOut();
|
||||
await invalidateAll();
|
||||
}
|
||||
```
|
||||
|
||||
**Use when:** Single auth entry point, simple apps.
|
||||
|
||||
## Solution B: Auth State Listener (Robust)
|
||||
|
||||
Set up a listener in root layout that auto-invalidates on auth changes:
|
||||
|
||||
```svelte
|
||||
<!-- +layout.svelte -->
|
||||
<script>
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { auth_client } from '$lib/auth-client';
|
||||
|
||||
onMount(() => {
|
||||
// Listen for auth state changes
|
||||
const unsubscribe = auth_client.onAuthStateChange(() => {
|
||||
invalidateAll();
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
**Use when:** Multiple auth flows (OAuth, magic links, etc.), complex apps.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### Not awaiting invalidateAll()
|
||||
|
||||
```typescript
|
||||
// WRONG - race condition
|
||||
invalidateAll();
|
||||
goto('/');
|
||||
|
||||
// RIGHT - wait for data refresh
|
||||
await invalidateAll();
|
||||
goto('/');
|
||||
```
|
||||
|
||||
### Destructuring layout data
|
||||
|
||||
```svelte
|
||||
<!-- WRONG - static snapshot -->
|
||||
<script>
|
||||
let { data } = $props();
|
||||
const { user } = data; // Never updates after invalidateAll()
|
||||
</script>
|
||||
|
||||
<!-- RIGHT - reactive access -->
|
||||
<script>
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
{data.user?.email}
|
||||
```
|
||||
|
||||
## When invalidateAll() Runs
|
||||
|
||||
Per [Svelte docs](https://svelte.dev/tutorial/kit/invalidate-all):
|
||||
|
||||
> `invalidateAll()` is the nuclear option - it re-runs ALL load functions
|
||||
> for the current page, regardless of dependencies.
|
||||
|
||||
This includes:
|
||||
- `+layout.server.ts` (all levels)
|
||||
- `+layout.ts` (all levels)
|
||||
- `+page.server.ts`
|
||||
- `+page.ts`
|
||||
|
||||
## Comparison with Server-Side Auth
|
||||
|
||||
| Approach | Auth Location | Invalidation |
|
||||
|----------|--------------|--------------|
|
||||
| Form actions | Server (`+page.server.ts`) | Automatic (page reload) |
|
||||
| Client auth | Browser (auth_client) | Manual (`invalidateAll()`) |
|
||||
|
||||
Form actions with `throw redirect()` cause a full navigation, which
|
||||
naturally re-runs load functions. Client-side auth with `goto()` does not.
|
||||
|
||||
## See Also
|
||||
|
||||
- [error-redirect-handling.md](error-redirect-handling.md) - Server-side redirects
|
||||
- [load-functions.md](load-functions.md) - Server vs universal loads
|
||||
@@ -0,0 +1,407 @@
|
||||
# Error & Redirect Handling: fail(), redirect(), error()
|
||||
|
||||
## The Three Ways to Handle Failures
|
||||
|
||||
| Function | When | Must Throw? | Use Case |
|
||||
| ------------ | ---------------- | ----------- | ------------------------------------- |
|
||||
| `fail()` | Validation error | No (return) | Form validation errors |
|
||||
| `redirect()` | Navigate user | **YES** | After successful action |
|
||||
| `error()` | Fatal error | **YES** | Unauthorized, not found, server error |
|
||||
|
||||
## fail() - Validation Errors
|
||||
|
||||
**Return** (don't throw) from form actions to show validation errors:
|
||||
|
||||
```typescript
|
||||
import { fail } from '@sveltejs/kit';
|
||||
|
||||
export const actions = {
|
||||
default: async ({ request }) => {
|
||||
const data = await request.formData();
|
||||
const email = data.get('email');
|
||||
|
||||
// Validation failed - return fail()
|
||||
if (!email || !email.includes('@')) {
|
||||
return fail(400, {
|
||||
email,
|
||||
error: 'Invalid email',
|
||||
missing: !email,
|
||||
});
|
||||
}
|
||||
|
||||
// Success - process and maybe redirect
|
||||
await processEmail(email);
|
||||
throw redirect(303, '/success');
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
```svelte
|
||||
<!-- +page.svelte -->
|
||||
<script>
|
||||
export let form; // Contains fail() return value
|
||||
</script>
|
||||
|
||||
{#if form?.error}
|
||||
<p class="error">{form.error}</p>
|
||||
{/if}
|
||||
|
||||
<form method="POST">
|
||||
<input name="email" value={form?.email ?? ''} />
|
||||
|
||||
{#if form?.missing}
|
||||
<span>Email is required</span>
|
||||
{/if}
|
||||
|
||||
<button>Submit</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
|
||||
- **Return** fail() (don't throw)
|
||||
- Status code (400 = bad request)
|
||||
- Return validation errors + form data to repopulate fields
|
||||
- Accessible via `form` prop in page component
|
||||
- Page stays on same URL
|
||||
|
||||
## redirect() - Navigation
|
||||
|
||||
**Throw** redirect() to navigate user to another page:
|
||||
|
||||
```typescript
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const actions = {
|
||||
login: async ({ request, cookies }) => {
|
||||
const data = await request.formData();
|
||||
|
||||
const user = await authenticate(data);
|
||||
|
||||
if (!user) {
|
||||
return fail(401, { error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
cookies.set('session', user.sessionToken, { path: '/' });
|
||||
|
||||
// Success - redirect to dashboard
|
||||
throw redirect(303, '/dashboard'); // MUST throw
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
**Status codes:**
|
||||
|
||||
- `303` - See Other (recommended for POST → GET redirect)
|
||||
- `301` - Moved Permanently
|
||||
- `302` - Found (temporary redirect)
|
||||
- `307` - Temporary Redirect (preserves method)
|
||||
- `308` - Permanent Redirect (preserves method)
|
||||
|
||||
**Use 303** for most cases (especially after form submission).
|
||||
|
||||
**Key points:**
|
||||
|
||||
- **MUST throw** (not return)
|
||||
- Use status 303 for form actions
|
||||
- Can redirect to external URLs
|
||||
- Can use relative paths: `throw redirect(303, '..')`
|
||||
|
||||
## error() - Fatal Errors
|
||||
|
||||
**Throw** error() for unrecoverable errors (auth, not found, server
|
||||
error):
|
||||
|
||||
```typescript
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
export const load = async ({ params, locals }) => {
|
||||
const post = await db.query.posts.findFirst({
|
||||
where: eq(posts.id, params.id),
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
throw error(404, 'Post not found'); // MUST throw
|
||||
}
|
||||
|
||||
if (post.authorId !== locals.userId) {
|
||||
throw error(403, 'Forbidden'); // MUST throw
|
||||
}
|
||||
|
||||
return { post };
|
||||
};
|
||||
```
|
||||
|
||||
**Common status codes:**
|
||||
|
||||
- `400` - Bad Request
|
||||
- `401` - Unauthorized (not logged in)
|
||||
- `403` - Forbidden (logged in but no permission)
|
||||
- `404` - Not Found
|
||||
- `500` - Internal Server Error
|
||||
|
||||
**With custom error page:**
|
||||
|
||||
```typescript
|
||||
// src/routes/posts/[id]/+page.server.ts
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
export const load = async ({ params }) => {
|
||||
const post = await getPost(params.id);
|
||||
|
||||
if (!post) {
|
||||
throw error(404, {
|
||||
message: 'Post not found',
|
||||
postId: params.id,
|
||||
});
|
||||
}
|
||||
|
||||
return { post };
|
||||
};
|
||||
```
|
||||
|
||||
```svelte
|
||||
<!-- src/routes/posts/[id]/+error.svelte -->
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
</script>
|
||||
|
||||
<h1>{$page.status}: {$page.error.message}</h1>
|
||||
|
||||
{#if $page.error.postId}
|
||||
<p>Could not find post with ID: {$page.error.postId}</p>
|
||||
{/if}
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
|
||||
- **MUST throw** (not return)
|
||||
- Renders closest `+error.svelte` file
|
||||
- Accessible via `$page.status` and `$page.error`
|
||||
- Stops load function execution
|
||||
- Use for authorization, not found, server errors
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### ❌ Not Throwing redirect()
|
||||
|
||||
```typescript
|
||||
// WRONG
|
||||
export const actions = {
|
||||
default: async () => {
|
||||
redirect(303, '/home'); // DOESN'T WORK
|
||||
},
|
||||
};
|
||||
|
||||
// RIGHT
|
||||
export const actions = {
|
||||
default: async () => {
|
||||
throw redirect(303, '/home'); // MUST throw
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### ❌ Not Throwing error()
|
||||
|
||||
```typescript
|
||||
// WRONG
|
||||
export const load = async () => {
|
||||
error(404, 'Not found'); // DOESN'T WORK
|
||||
};
|
||||
|
||||
// RIGHT
|
||||
export const load = async () => {
|
||||
throw error(404, 'Not found'); // MUST throw
|
||||
};
|
||||
```
|
||||
|
||||
### ❌ Throwing fail()
|
||||
|
||||
```typescript
|
||||
// WRONG
|
||||
export const actions = {
|
||||
default: async () => {
|
||||
throw fail(400, { error: 'Bad' }); // Don't throw
|
||||
},
|
||||
};
|
||||
|
||||
// RIGHT
|
||||
export const actions = {
|
||||
default: async () => {
|
||||
return fail(400, { error: 'Bad' }); // Return
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### ❌ Catching redirect Without Rethrowing
|
||||
|
||||
```typescript
|
||||
// WRONG
|
||||
export const actions = {
|
||||
default: async () => {
|
||||
try {
|
||||
await doSomething();
|
||||
throw redirect(303, '/success');
|
||||
} catch (e) {
|
||||
console.error(e); // Catches redirect - it won't work!
|
||||
return fail(500, { error: 'Failed' });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// RIGHT
|
||||
import { isRedirect } from '@sveltejs/kit';
|
||||
|
||||
export const actions = {
|
||||
default: async () => {
|
||||
try {
|
||||
await doSomething();
|
||||
throw redirect(303, '/success');
|
||||
} catch (e) {
|
||||
if (isRedirect(e)) throw e; // Rethrow redirect
|
||||
console.error(e);
|
||||
return fail(500, { error: 'Failed' });
|
||||
}
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Decision Tree
|
||||
|
||||
```
|
||||
Problem in form action?
|
||||
├─ Validation error (show to user) → return fail(400, { errors })
|
||||
├─ Success (navigate) → throw redirect(303, '/success')
|
||||
└─ Fatal error (auth, not found) → throw error(403, 'Forbidden')
|
||||
|
||||
Problem in load function?
|
||||
├─ Data not found → throw error(404, 'Not found')
|
||||
├─ Unauthorized → throw error(401, 'Unauthorized')
|
||||
├─ Forbidden → throw error(403, 'Forbidden')
|
||||
└─ Server error → throw error(500, 'Server error')
|
||||
```
|
||||
|
||||
## Patterns
|
||||
|
||||
### Pattern 1: Validate, Process, Redirect
|
||||
|
||||
```typescript
|
||||
export const actions = {
|
||||
create: async ({ request }) => {
|
||||
const data = await request.formData();
|
||||
|
||||
// 1. Validate
|
||||
if (!isValid(data)) {
|
||||
return fail(400, { errors: getErrors(data) });
|
||||
}
|
||||
|
||||
// 2. Process
|
||||
const post = await createPost(data);
|
||||
|
||||
// 3. Redirect
|
||||
throw redirect(303, `/posts/${post.id}`);
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Pattern 2: Check Auth, Load Data
|
||||
|
||||
```typescript
|
||||
export const load = async ({ locals, params }) => {
|
||||
// 1. Check auth
|
||||
if (!locals.user) {
|
||||
throw error(401, 'Please log in');
|
||||
}
|
||||
|
||||
// 2. Load data
|
||||
const post = await getPost(params.id);
|
||||
|
||||
if (!post) {
|
||||
throw error(404, 'Post not found');
|
||||
}
|
||||
|
||||
// 3. Check permission
|
||||
if (post.authorId !== locals.user.id) {
|
||||
throw error(403, 'Not your post');
|
||||
}
|
||||
|
||||
return { post };
|
||||
};
|
||||
```
|
||||
|
||||
### Pattern 3: Conditional Redirect
|
||||
|
||||
```typescript
|
||||
export const load = async ({ locals }) => {
|
||||
// Redirect if already logged in
|
||||
if (locals.user) {
|
||||
throw redirect(303, '/dashboard');
|
||||
}
|
||||
|
||||
return {}; // Show login page
|
||||
};
|
||||
```
|
||||
|
||||
## Summary Table
|
||||
|
||||
| | fail() | redirect() | error() |
|
||||
| -------------------- | ----------------- | --------------- | --------------- |
|
||||
| **Throw or return?** | Return | **Throw** | **Throw** |
|
||||
| **Use in** | Form actions | Actions & load | Actions & load |
|
||||
| **Purpose** | Validation errors | Navigate | Fatal errors |
|
||||
| **Status codes** | 400-499 | 301-308 | 400-599 |
|
||||
| **Accessible via** | `form` prop | N/A (navigates) | `+error.svelte` |
|
||||
| **Stays on page?** | Yes | No (navigates) | No (error page) |
|
||||
| **Example** | Invalid email | After save | Not found |
|
||||
|
||||
## Client-Side Navigation After Auth
|
||||
|
||||
When using client-side auth (e.g., Better Auth client), **always use `goto()` with `invalidateAll: true`** to ensure layout data refreshes:
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { goto } from '$app/navigation';
|
||||
import { auth_client } from '$lib/auth-client';
|
||||
|
||||
async function handle_signin() {
|
||||
const result = await auth_client.signIn.email({ email, password });
|
||||
|
||||
if (!result.error) {
|
||||
// ✅ CORRECT: invalidateAll ensures layout load functions re-run
|
||||
await goto('/dashboard', { invalidateAll: true });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### ❌ Common Mistake: Separate invalidateAll + goto
|
||||
|
||||
```typescript
|
||||
// WRONG: Data might not refresh before navigation
|
||||
await invalidateAll();
|
||||
goto('/dashboard');
|
||||
|
||||
// ALSO WRONG: goto doesn't wait for invalidation
|
||||
await invalidateAll();
|
||||
await goto('/dashboard');
|
||||
```
|
||||
|
||||
### ✅ Correct Pattern
|
||||
|
||||
```typescript
|
||||
// RIGHT: Single call with invalidateAll option
|
||||
await goto('/dashboard', { invalidateAll: true });
|
||||
```
|
||||
|
||||
**Why this matters:** After client-side auth, cookies are set but the root layout's `load` function (which typically checks `auth.api.getSession()`) has cached data. Using `invalidateAll: true` forces all load functions to re-run with the new session cookie.
|
||||
|
||||
**Note:** Server-side redirects with `throw redirect()` don't have this problem because they trigger a full page load.
|
||||
|
||||
## Quick Checklist
|
||||
|
||||
- ✅ Using `return fail()` for validation errors?
|
||||
- ✅ Using `throw redirect()` (not return) after success?
|
||||
- ✅ Using `throw error()` (not return) for fatal errors?
|
||||
- ✅ Not catching redirects/errors without rethrowing?
|
||||
- ✅ Using status code 303 for POST redirects?
|
||||
- ✅ Using `goto(url, { invalidateAll: true })` after client-side auth?
|
||||
@@ -0,0 +1,316 @@
|
||||
# Form Actions
|
||||
|
||||
## Basic Pattern
|
||||
|
||||
Form actions live in `+page.server.ts` and handle form submissions:
|
||||
|
||||
```typescript
|
||||
// +page.server.ts
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import type { Actions } from './$types';
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request }) => {
|
||||
const data = await request.formData();
|
||||
const email = data.get('email');
|
||||
const password = data.get('password');
|
||||
|
||||
if (!email) {
|
||||
return fail(400, { email, missing: true });
|
||||
}
|
||||
|
||||
await login(email, password);
|
||||
throw redirect(303, '/dashboard');
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
```svelte
|
||||
<!-- +page.svelte -->
|
||||
<script>
|
||||
export let form; // Contains return value from action
|
||||
</script>
|
||||
|
||||
<form method="POST">
|
||||
<input name="email" value={form?.email ?? ''} />
|
||||
{#if form?.missing}
|
||||
<p class="error">Email is required</p>
|
||||
{/if}
|
||||
|
||||
<button>Login</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
## Named Actions
|
||||
|
||||
```typescript
|
||||
export const actions: Actions = {
|
||||
login: async ({ request }) => {
|
||||
// Handle login
|
||||
},
|
||||
|
||||
register: async ({ request }) => {
|
||||
// Handle registration
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
```svelte
|
||||
<form method="POST" action="?/login">...</form>
|
||||
<form method="POST" action="?/register">...</form>
|
||||
```
|
||||
|
||||
## Return Values
|
||||
|
||||
### Option 1: fail() - Show Error to User
|
||||
|
||||
```typescript
|
||||
import { fail } from '@sveltejs/kit';
|
||||
|
||||
export const actions = {
|
||||
default: async ({ request }) => {
|
||||
const data = await request.formData();
|
||||
|
||||
if (!isValid(data)) {
|
||||
return fail(400, {
|
||||
error: 'Invalid data',
|
||||
fields: Object.fromEntries(data), // Preserve input
|
||||
});
|
||||
}
|
||||
|
||||
// Success - no return needed
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Option 2: redirect() - Navigate After Success
|
||||
|
||||
```typescript
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const actions = {
|
||||
default: async ({ request }) => {
|
||||
await processForm(request);
|
||||
throw redirect(303, '/success'); // MUST throw
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Option 3: error() - Fatal Error
|
||||
|
||||
```typescript
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
export const actions = {
|
||||
default: async ({ request }) => {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
if (!user) {
|
||||
throw error(401, 'Unauthorized'); // MUST throw
|
||||
}
|
||||
|
||||
// Process...
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Progressive Enhancement
|
||||
|
||||
Form works without JavaScript:
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { enhance } from '$app/forms';
|
||||
</script>
|
||||
|
||||
<form method="POST" use:enhance>
|
||||
<!-- Works with or without JS -->
|
||||
</form>
|
||||
```
|
||||
|
||||
With custom handling:
|
||||
|
||||
```svelte
|
||||
<form
|
||||
method="POST"
|
||||
use:enhance={({ formData, cancel }) => {
|
||||
// Before submit
|
||||
formData.append('timestamp', Date.now().toString());
|
||||
|
||||
return async ({ result, update }) => {
|
||||
// After response
|
||||
if (result.type === 'success') {
|
||||
await update(); // Update form prop
|
||||
}
|
||||
};
|
||||
}}
|
||||
>
|
||||
...
|
||||
</form>
|
||||
```
|
||||
|
||||
## Validation Pattern
|
||||
|
||||
```typescript
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { z } from 'zod';
|
||||
|
||||
const schema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
});
|
||||
|
||||
export const actions = {
|
||||
default: async ({ request }) => {
|
||||
const data = await request.formData();
|
||||
const rawData = {
|
||||
email: data.get('email'),
|
||||
password: data.get('password'),
|
||||
};
|
||||
|
||||
const result = schema.safeParse(rawData);
|
||||
|
||||
if (!result.success) {
|
||||
return fail(400, {
|
||||
errors: result.error.flatten().fieldErrors,
|
||||
data: rawData,
|
||||
});
|
||||
}
|
||||
|
||||
// result.data is validated
|
||||
await createUser(result.data);
|
||||
throw redirect(303, '/welcome');
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### ❌ Not Throwing redirect/error
|
||||
|
||||
```typescript
|
||||
// WRONG
|
||||
export const actions = {
|
||||
default: async () => {
|
||||
redirect(303, '/home'); // DOESN'T WORK - must throw
|
||||
},
|
||||
};
|
||||
|
||||
// RIGHT
|
||||
export const actions = {
|
||||
default: async () => {
|
||||
throw redirect(303, '/home');
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### ❌ Catching redirect Without Rethrowing
|
||||
|
||||
```typescript
|
||||
// WRONG
|
||||
export const actions = {
|
||||
default: async () => {
|
||||
try {
|
||||
await doSomething();
|
||||
throw redirect(303, '/success');
|
||||
} catch (e) {
|
||||
console.error(e); // Catches redirect!
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// RIGHT
|
||||
export const actions = {
|
||||
default: async () => {
|
||||
try {
|
||||
await doSomething();
|
||||
throw redirect(303, '/success');
|
||||
} catch (e) {
|
||||
if (e instanceof Redirect) throw e; // Rethrow
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### ❌ Returning non-serializable data
|
||||
|
||||
```typescript
|
||||
// WRONG
|
||||
export const actions = {
|
||||
default: async () => {
|
||||
return { date: new Date() }; // Date is not serializable
|
||||
},
|
||||
};
|
||||
|
||||
// RIGHT
|
||||
export const actions = {
|
||||
default: async () => {
|
||||
return { date: new Date().toISOString() };
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## File Upload
|
||||
|
||||
```typescript
|
||||
export const actions = {
|
||||
default: async ({ request }) => {
|
||||
const data = await request.formData();
|
||||
const file = data.get('file') as File;
|
||||
|
||||
if (!file || file.size === 0) {
|
||||
return fail(400, { error: 'No file uploaded' });
|
||||
}
|
||||
|
||||
const bytes = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(bytes);
|
||||
|
||||
await saveFile(buffer, file.name);
|
||||
throw redirect(303, '/uploads');
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
```svelte
|
||||
<form method="POST" enctype="multipart/form-data">
|
||||
<input type="file" name="file" required />
|
||||
<button>Upload</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
## Form Prop in Page
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
export let data; // From load function
|
||||
export let form; // From action return value
|
||||
</script>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="error">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
{#if form?.success}
|
||||
<div class="success">Success!</div>
|
||||
{/if}
|
||||
|
||||
<form method="POST">
|
||||
<input name="email" value={form?.email ?? data.email ?? ''} />
|
||||
{#if form?.errors?.email}
|
||||
<p>{form.errors.email}</p>
|
||||
{/if}
|
||||
|
||||
<button>Submit</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
## Key Rules
|
||||
|
||||
1. ✅ Actions must be in `+page.server.ts` (not `+page.ts`)
|
||||
2. ✅ ALWAYS throw `redirect()` and `error()` (not return)
|
||||
3. ✅ Return `fail()` for validation errors
|
||||
4. ✅ Return only serializable data
|
||||
5. ✅ Don't catch redirects/errors without rethrowing
|
||||
6. ✅ Use `enhance` for progressive enhancement
|
||||
7. ✅ Access FormData with `data.get('fieldName')`
|
||||
@@ -0,0 +1,292 @@
|
||||
# Load Functions: Server vs Universal
|
||||
|
||||
## Decision Matrix
|
||||
|
||||
| Need | Use | File |
|
||||
| ----------------------------------- | -------------- | ----------------- |
|
||||
| Database access | Server load | `+page.server.ts` |
|
||||
| Secrets/env vars | Server load | `+page.server.ts` |
|
||||
| Server-only packages | Server load | `+page.server.ts` |
|
||||
| Browser APIs (window, localStorage) | Universal load | `+page.ts` |
|
||||
| Client-side fetch | Universal load | `+page.ts` |
|
||||
| Runs on both | Universal load | `+page.ts` |
|
||||
|
||||
## Server Load (+page.server.ts)
|
||||
|
||||
**When:** Need server-only resources (DB, secrets, server APIs)
|
||||
|
||||
**Runs:** Only on server (never in browser)
|
||||
|
||||
```typescript
|
||||
// src/routes/profile/+page.server.ts
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { db } from '$lib/server/database';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
// Access server-only resources
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.id, locals.userId),
|
||||
});
|
||||
|
||||
const posts = await db.query.posts.findMany({
|
||||
where: eq(posts.authorId, user.id),
|
||||
});
|
||||
|
||||
// Must return serializable data
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
},
|
||||
posts,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
|
||||
- Runs only on server
|
||||
- Can access `$lib/server/*` imports
|
||||
- Can use secrets from `env` safely
|
||||
- Return values must be JSON-serializable
|
||||
- Output is automatically passed to universal load
|
||||
|
||||
## Universal Load (+page.ts)
|
||||
|
||||
**When:** Need to run on both server and client, or need browser APIs
|
||||
|
||||
**Runs:** Server (during SSR) AND client (during navigation)
|
||||
|
||||
```typescript
|
||||
// src/routes/dashboard/+page.ts
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async ({ data, fetch }) => {
|
||||
// `data` comes from +page.server.ts if it exists
|
||||
const { user } = data;
|
||||
|
||||
// Fetch additional data (works on both server and client)
|
||||
const response = await fetch('/api/stats');
|
||||
const stats = await response.json();
|
||||
|
||||
// Can access browser APIs (but check if in browser first)
|
||||
const theme =
|
||||
typeof window !== 'undefined'
|
||||
? localStorage.getItem('theme')
|
||||
: null;
|
||||
|
||||
return {
|
||||
user,
|
||||
stats,
|
||||
theme,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
|
||||
- Runs on both server AND client
|
||||
- Receives server load output as `data` parameter
|
||||
- Use SvelteKit's `fetch` (automatically handles SSR)
|
||||
- Check `typeof window !== 'undefined'` for browser APIs
|
||||
- Cannot import from `$lib/server/*`
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
Request → Server Load (+page.server.ts)
|
||||
↓ (returns { user })
|
||||
Universal Load (+page.ts)
|
||||
↓ (receives data: { user }, returns { user, stats })
|
||||
Page Component (+page.svelte)
|
||||
↓ (receives data: { user, stats })
|
||||
```
|
||||
|
||||
**Example:**
|
||||
|
||||
```typescript
|
||||
// +page.server.ts
|
||||
export const load = async () => {
|
||||
return { serverData: 'from server' };
|
||||
};
|
||||
|
||||
// +page.ts
|
||||
export const load = async ({ data }) => {
|
||||
console.log(data.serverData); // 'from server'
|
||||
return { ...data, clientData: 'from universal' };
|
||||
};
|
||||
|
||||
// +page.svelte
|
||||
<script>
|
||||
export let data; // { serverData, clientData }
|
||||
</script>
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern 1: Server + Universal
|
||||
|
||||
```typescript
|
||||
// +page.server.ts - Fetch sensitive data
|
||||
export const load = async ({ locals }) => {
|
||||
const user = await getUser(locals.session);
|
||||
return { user };
|
||||
};
|
||||
|
||||
// +page.ts - Fetch public data
|
||||
export const load = async ({ data, fetch }) => {
|
||||
const publicPosts = await fetch('/api/posts').then((r) => r.json());
|
||||
return { ...data, publicPosts };
|
||||
};
|
||||
```
|
||||
|
||||
### Pattern 2: Conditional Universal Load
|
||||
|
||||
```typescript
|
||||
// +page.ts
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export const load = async ({ fetch }) => {
|
||||
const serverData = await fetch('/api/data').then((r) => r.json());
|
||||
|
||||
// Only run in browser
|
||||
let clientOnlyData = null;
|
||||
if (browser) {
|
||||
clientOnlyData = localStorage.getItem('cache');
|
||||
}
|
||||
|
||||
return { serverData, clientOnlyData };
|
||||
};
|
||||
```
|
||||
|
||||
### Pattern 3: Depends for Revalidation
|
||||
|
||||
```typescript
|
||||
// +page.ts
|
||||
export const load = async ({ fetch, depends }) => {
|
||||
depends('app:posts'); // Invalidate with invalidate('app:posts')
|
||||
|
||||
const posts = await fetch('/api/posts').then((r) => r.json());
|
||||
return { posts };
|
||||
};
|
||||
|
||||
// Somewhere else:
|
||||
import { invalidate } from '$app/navigation';
|
||||
invalidate('app:posts'); // Re-runs load function
|
||||
```
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### ❌ Importing Server Code in Universal Load
|
||||
|
||||
```typescript
|
||||
// +page.ts - WRONG
|
||||
import { db } from '$lib/server/database'; // ERROR - can't import server code
|
||||
|
||||
export const load = async () => {
|
||||
const users = await db.query.users.findMany(); // Won't work
|
||||
return { users };
|
||||
};
|
||||
```
|
||||
|
||||
**Fix:** Move to `+page.server.ts`
|
||||
|
||||
### ❌ Returning Non-Serializable Data
|
||||
|
||||
```typescript
|
||||
// +page.server.ts - WRONG
|
||||
export const load = async () => {
|
||||
const user = await User.findOne(); // Returns class instance
|
||||
return { user }; // ERROR - class instances aren't serializable
|
||||
};
|
||||
```
|
||||
|
||||
**Fix:** Return plain objects
|
||||
|
||||
```typescript
|
||||
export const load = async () => {
|
||||
const user = await User.findOne();
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
},
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### ❌ Using window/localStorage Without Check
|
||||
|
||||
```typescript
|
||||
// +page.ts - WRONG
|
||||
export const load = async () => {
|
||||
const theme = localStorage.getItem('theme'); // ERROR on server
|
||||
return { theme };
|
||||
};
|
||||
```
|
||||
|
||||
**Fix:** Check for browser
|
||||
|
||||
```typescript
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export const load = async () => {
|
||||
const theme = browser ? localStorage.getItem('theme') : 'light';
|
||||
return { theme };
|
||||
};
|
||||
```
|
||||
|
||||
## TypeScript
|
||||
|
||||
```typescript
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({
|
||||
locals,
|
||||
params,
|
||||
url,
|
||||
}) => {
|
||||
// TypeScript knows return type must be serializable
|
||||
return {
|
||||
user: {
|
||||
id: 1,
|
||||
name: 'Alice',
|
||||
},
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
```typescript
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async ({ data, fetch, params }) => {
|
||||
// `data` is typed from +page.server.ts return type
|
||||
return {
|
||||
...data,
|
||||
extra: 'data',
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## When to Use Which
|
||||
|
||||
**Use Server Load when:**
|
||||
|
||||
- Need database access
|
||||
- Need secrets/environment variables
|
||||
- Need server-only npm packages
|
||||
- Need to hide implementation details
|
||||
|
||||
**Use Universal Load when:**
|
||||
|
||||
- Need browser APIs (localStorage, window)
|
||||
- Need to fetch public APIs (works on both)
|
||||
- Want client-side navigation without server round-trip
|
||||
- Data is public and doesn't need server resources
|
||||
|
||||
**Use Both when:**
|
||||
|
||||
- Server load fetches sensitive data
|
||||
- Universal load fetches public data or adds client-side enhancements
|
||||
@@ -0,0 +1,317 @@
|
||||
# Serialization: What Can/Can't Be Returned
|
||||
|
||||
## The Rule
|
||||
|
||||
**Server load functions and form actions must return JSON-serializable
|
||||
data.**
|
||||
|
||||
Data travels from server → client as JSON. Non-JSON types break.
|
||||
|
||||
## ✅ Serializable (Safe)
|
||||
|
||||
| Type | Example | Notes |
|
||||
| ------------ | ------------------------- | --------------------------------- |
|
||||
| String | `"hello"` | ✅ |
|
||||
| Number | `42`, `3.14` | ✅ |
|
||||
| Boolean | `true`, `false` | ✅ |
|
||||
| null | `null` | ✅ |
|
||||
| Array | `[1, 2, 3]` | ✅ |
|
||||
| Plain Object | `{ name: 'Alice' }` | ✅ |
|
||||
| Nested | `{ user: { posts: [] } }` | ✅ If all values are serializable |
|
||||
|
||||
## ❌ NOT Serializable (Breaks)
|
||||
|
||||
| Type | Example | Why | Fix |
|
||||
| -------------- | -------------- | -------------------------- | -------------------------------------------- |
|
||||
| Date | `new Date()` | Becomes string | Use `.toISOString()` |
|
||||
| undefined | `undefined` | Removed from JSON | Use `null` |
|
||||
| Function | `() => {}` | Can't serialize | Remove or convert to data |
|
||||
| Class instance | `new User()` | Only serializes properties | Convert to plain object |
|
||||
| Map | `new Map()` | Becomes `{}` | Convert to object: `Object.fromEntries(map)` |
|
||||
| Set | `new Set()` | Becomes `{}` | Convert to array: `Array.from(set)` |
|
||||
| BigInt | `123n` | Error | Convert to string |
|
||||
| Symbol | `Symbol('id')` | Removed | Don't use |
|
||||
| RegExp | `/test/` | Becomes `{}` | Convert to string |
|
||||
| Error | `new Error()` | Loses stack | Extract message/code |
|
||||
|
||||
## Examples
|
||||
|
||||
### ❌ Wrong: Returning Date
|
||||
|
||||
```typescript
|
||||
// +page.server.ts - WRONG
|
||||
export const load = async () => {
|
||||
return {
|
||||
createdAt: new Date(), // Serializes as string, not Date object
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
```svelte
|
||||
<!-- +page.svelte -->
|
||||
<script>
|
||||
export let data;
|
||||
console.log(data.createdAt); // String, not Date
|
||||
console.log(data.createdAt.getTime()); // ERROR - not a Date
|
||||
</script>
|
||||
```
|
||||
|
||||
### ✅ Right: Convert Date to ISO String
|
||||
|
||||
```typescript
|
||||
// +page.server.ts - RIGHT
|
||||
export const load = async () => {
|
||||
const user = await db.users.findFirst();
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
createdAt: user.createdAt.toISOString(), // Convert to string
|
||||
},
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
```svelte
|
||||
<!-- +page.svelte -->
|
||||
<script>
|
||||
export let data;
|
||||
const createdAt = new Date(data.user.createdAt); // Parse back to Date
|
||||
</script>
|
||||
```
|
||||
|
||||
### ❌ Wrong: Returning Class Instance
|
||||
|
||||
```typescript
|
||||
// +page.server.ts - WRONG
|
||||
class User {
|
||||
constructor(
|
||||
public id: number,
|
||||
public name: string,
|
||||
) {}
|
||||
|
||||
getDisplayName() {
|
||||
return `User: ${this.name}`;
|
||||
}
|
||||
}
|
||||
|
||||
export const load = async () => {
|
||||
const user = new User(1, 'Alice');
|
||||
return { user }; // Methods are lost during serialization
|
||||
};
|
||||
```
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
export let data;
|
||||
console.log(data.user.getDisplayName()); // ERROR - method doesn't exist
|
||||
</script>
|
||||
```
|
||||
|
||||
### ✅ Right: Return Plain Object
|
||||
|
||||
```typescript
|
||||
// +page.server.ts - RIGHT
|
||||
export const load = async () => {
|
||||
const user = await db.users.findFirst();
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
// Only plain data, no methods
|
||||
},
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### ❌ Wrong: undefined Values
|
||||
|
||||
```typescript
|
||||
// +page.server.ts - WRONG
|
||||
export const load = async () => {
|
||||
return {
|
||||
name: 'Alice',
|
||||
email: undefined, // Removed during JSON.stringify
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
export let data;
|
||||
console.log('email' in data); // false - key is missing!
|
||||
</script>
|
||||
```
|
||||
|
||||
### ✅ Right: Use null
|
||||
|
||||
```typescript
|
||||
// +page.server.ts - RIGHT
|
||||
export const load = async () => {
|
||||
return {
|
||||
name: 'Alice',
|
||||
email: null, // Preserved
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### ❌ Wrong: Map/Set
|
||||
|
||||
```typescript
|
||||
// +page.server.ts - WRONG
|
||||
export const load = async () => {
|
||||
const tags = new Set(['svelte', 'typescript']);
|
||||
const metadata = new Map([['version', '1.0']]);
|
||||
|
||||
return {
|
||||
tags, // Becomes {}
|
||||
metadata, // Becomes {}
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### ✅ Right: Convert to Array/Object
|
||||
|
||||
```typescript
|
||||
// +page.server.ts - RIGHT
|
||||
export const load = async () => {
|
||||
const tags = new Set(['svelte', 'typescript']);
|
||||
const metadata = new Map([['version', '1.0']]);
|
||||
|
||||
return {
|
||||
tags: Array.from(tags), // ['svelte', 'typescript']
|
||||
metadata: Object.fromEntries(metadata), // { version: '1.0' }
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## ORM Returns (Drizzle, Prisma)
|
||||
|
||||
Most ORMs return plain objects with Date fields:
|
||||
|
||||
```typescript
|
||||
// +page.server.ts
|
||||
export const load = async () => {
|
||||
const user = await db.query.users.findFirst();
|
||||
// user = { id: 1, name: 'Alice', createdAt: Date }
|
||||
|
||||
return {
|
||||
user: {
|
||||
...user,
|
||||
createdAt: user.createdAt.toISOString(), // Convert Date
|
||||
},
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Or use a helper:
|
||||
|
||||
```typescript
|
||||
function serialize<T extends Record<string, any>>(obj: T): T {
|
||||
return JSON.parse(JSON.stringify(obj)); // Forces serialization
|
||||
}
|
||||
|
||||
export const load = async () => {
|
||||
const user = await db.query.users.findFirst();
|
||||
return { user: serialize(user) };
|
||||
};
|
||||
```
|
||||
|
||||
## BigInt
|
||||
|
||||
```typescript
|
||||
// +page.server.ts - WRONG
|
||||
export const load = async () => {
|
||||
return {
|
||||
userId: 123456789012345678901234567890n, // ERROR - can't serialize
|
||||
};
|
||||
};
|
||||
|
||||
// +page.server.ts - RIGHT
|
||||
export const load = async () => {
|
||||
return {
|
||||
userId: '123456789012345678901234567890', // String
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## Detecting Serialization Issues
|
||||
|
||||
SvelteKit will throw an error if you try to return non-serializable
|
||||
data:
|
||||
|
||||
```
|
||||
Error: Data returned from `load` while rendering / is not serializable:
|
||||
- Cannot stringify arbitrary non-POJOs
|
||||
```
|
||||
|
||||
## Testing Serialization
|
||||
|
||||
```typescript
|
||||
const data = { user: new User() };
|
||||
|
||||
try {
|
||||
JSON.parse(JSON.stringify(data));
|
||||
console.log('✅ Serializable');
|
||||
} catch (e) {
|
||||
console.log('❌ Not serializable');
|
||||
}
|
||||
```
|
||||
|
||||
## TypeScript Help
|
||||
|
||||
Use `satisfies` to catch issues at compile time:
|
||||
|
||||
```typescript
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load = (async () => {
|
||||
return {
|
||||
user: {
|
||||
id: 1,
|
||||
name: 'Alice',
|
||||
createdAt: new Date(), // TypeScript allows this, but it's problematic
|
||||
},
|
||||
};
|
||||
}) satisfies PageServerLoad;
|
||||
```
|
||||
|
||||
Better: Create a type that only allows primitives:
|
||||
|
||||
```typescript
|
||||
type Serializable =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| { [key: string]: Serializable }
|
||||
| Serializable[];
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const data: Serializable = {
|
||||
user: {
|
||||
id: 1,
|
||||
name: 'Alice',
|
||||
createdAt: new Date().toISOString(), // Must be string
|
||||
},
|
||||
};
|
||||
|
||||
return data;
|
||||
};
|
||||
```
|
||||
|
||||
## Quick Checklist
|
||||
|
||||
Before returning from server load or form action:
|
||||
|
||||
1. ✅ All values are string, number, boolean, null, array, or plain
|
||||
object?
|
||||
2. ✅ No Date objects? (convert to `.toISOString()`)
|
||||
3. ✅ No undefined? (use null)
|
||||
4. ✅ No class instances? (convert to plain objects)
|
||||
5. ✅ No Map/Set? (convert to object/array)
|
||||
6. ✅ No functions?
|
||||
7. ✅ No BigInt? (convert to string)
|
||||
@@ -0,0 +1,17 @@
|
||||
# Sveltekit Structure
|
||||
|
||||
SvelteKit structure guidance. Use for routing, layouts, error
|
||||
handling, and SSR. Covers file naming (+page vs +layout vs +server),
|
||||
nested layouts, error boundaries, and hydration.
|
||||
|
||||
## Structure
|
||||
|
||||
- `SKILL.md` - Main skill instructions
|
||||
- `references/` - Detailed documentation loaded as needed
|
||||
- `scripts/` - Executable code for deterministic operations
|
||||
- `assets/` - Templates, images, or other resources
|
||||
|
||||
## Usage
|
||||
|
||||
This skill is automatically discovered by Claude when relevant to the
|
||||
task.
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
name: sveltekit-structure
|
||||
# IMPORTANT: Keep description on ONE line for Claude Code compatibility
|
||||
# prettier-ignore
|
||||
description: SvelteKit structure guidance. Use for routing, layouts, error handling, SSR, or svelte:boundary. Covers file naming, nested layouts, error boundaries, pending UI, and hydration.
|
||||
---
|
||||
|
||||
# SvelteKit Structure
|
||||
|
||||
## Quick Start
|
||||
|
||||
**File types:** `+page.svelte` (page) | `+layout.svelte` (wrapper) |
|
||||
`+error.svelte` (error boundary) | `+server.ts` (API endpoint)
|
||||
|
||||
**Routes:** `src/routes/about/+page.svelte` → `/about` |
|
||||
`src/routes/posts/[id]/+page.svelte` → `/posts/123`
|
||||
|
||||
**Layouts:** Apply to all child routes. `+layout.svelte` at any level
|
||||
wraps descendants.
|
||||
|
||||
## Example
|
||||
|
||||
```
|
||||
src/routes/
|
||||
├── +layout.svelte # Root layout (all pages)
|
||||
├── +page.svelte # Homepage /
|
||||
├── about/+page.svelte # /about
|
||||
└── dashboard/
|
||||
├── +layout.svelte # Dashboard layout (dashboard pages only)
|
||||
├── +page.svelte # /dashboard
|
||||
└── settings/+page.svelte # /dashboard/settings
|
||||
```
|
||||
|
||||
```svelte
|
||||
<!-- +layout.svelte -->
|
||||
<script>
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<nav><!-- Navigation --></nav>
|
||||
<main>{@render children()}</main>
|
||||
<footer><!-- Footer --></footer>
|
||||
```
|
||||
|
||||
## Reference Files
|
||||
|
||||
- [file-naming.md](references/file-naming.md) - File naming
|
||||
conventions
|
||||
- [layout-patterns.md](references/layout-patterns.md) - Nested layouts
|
||||
- [error-handling.md](references/error-handling.md) - +error.svelte
|
||||
placement
|
||||
- [svelte-boundary.md](references/svelte-boundary.md) -
|
||||
Component-level error/pending
|
||||
- [ssr-hydration.md](references/ssr-hydration.md) - SSR and
|
||||
browser-only code
|
||||
|
||||
## Notes
|
||||
|
||||
- Layouts: `{@render children()}` | Errors: +error.svelte _above_
|
||||
failing route
|
||||
- Groups: `(name)` folders don't affect URL | Client-only: check
|
||||
`browser`
|
||||
- **Last verified:** 2025-01-11
|
||||
|
||||
<!--
|
||||
PROGRESSIVE DISCLOSURE GUIDELINES:
|
||||
- Keep this file ~50 lines total (max ~150 lines)
|
||||
- Use 1-2 code blocks only (recommend 1)
|
||||
- Keep description <200 chars for Level 1 efficiency
|
||||
- Move detailed docs to references/ for Level 3 loading
|
||||
- This is Level 2 - quick reference ONLY, not a manual
|
||||
|
||||
LLM WORKFLOW (when editing this file):
|
||||
1. Write/edit SKILL.md
|
||||
2. Format (if formatter available)
|
||||
3. Run: claude-skills-cli validate <path>
|
||||
4. If multi-line description warning: run claude-skills-cli doctor <path>
|
||||
5. Validate again to confirm
|
||||
-->
|
||||
@@ -0,0 +1,274 @@
|
||||
# Error Handling
|
||||
|
||||
## Error Boundary Placement
|
||||
|
||||
**Key rule:** `+error.svelte` must be _above_ the failing route in the
|
||||
hierarchy.
|
||||
|
||||
```
|
||||
src/routes/
|
||||
├── +error.svelte # Catches errors in all routes below
|
||||
├── +page.svelte # If this errors → uses +error.svelte above
|
||||
└── admin/
|
||||
├── +error.svelte # Catches errors in admin routes
|
||||
└── +page.svelte # If this errors → uses admin/+error.svelte
|
||||
```
|
||||
|
||||
**Wrong:**
|
||||
|
||||
```
|
||||
src/routes/dashboard/
|
||||
├── +layout.svelte # If this errors...
|
||||
└── +error.svelte # This won't catch it (too low)
|
||||
```
|
||||
|
||||
**Right:**
|
||||
|
||||
```
|
||||
src/routes/
|
||||
├── +error.svelte # Catches dashboard layout errors
|
||||
└── dashboard/
|
||||
├── +layout.svelte
|
||||
└── +error.svelte # Catches dashboard page errors
|
||||
```
|
||||
|
||||
## Error Propagation
|
||||
|
||||
Errors bubble up to the nearest `+error.svelte`:
|
||||
|
||||
```
|
||||
src/routes/
|
||||
├── +error.svelte # Level 1 (root fallback)
|
||||
└── blog/
|
||||
├── +error.svelte # Level 2 (blog fallback)
|
||||
└── [slug]/
|
||||
├── +layout.server.ts # Error here → blog/+error.svelte
|
||||
├── +page.server.ts # Error here → blog/+error.svelte
|
||||
└── +page.svelte # Error here → blog/+error.svelte
|
||||
```
|
||||
|
||||
**If no error boundary exists at that level, it goes to the parent.**
|
||||
|
||||
## Basic Error Page
|
||||
|
||||
```svelte
|
||||
<!-- +error.svelte -->
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
</script>
|
||||
|
||||
<h1>{$page.status}</h1><p>{$page.error.message}</p>
|
||||
```
|
||||
|
||||
## Custom Error Data
|
||||
|
||||
```typescript
|
||||
// +page.server.ts
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
export const load = async ({ params }) => {
|
||||
const post = await getPost(params.id);
|
||||
|
||||
if (!post) {
|
||||
throw error(404, {
|
||||
message: 'Post not found',
|
||||
postId: params.id,
|
||||
});
|
||||
}
|
||||
|
||||
return { post };
|
||||
};
|
||||
```
|
||||
|
||||
```svelte
|
||||
<!-- +error.svelte -->
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
</script>
|
||||
|
||||
<h1>{$page.status}</h1>
|
||||
<p>{$page.error.message}</p>
|
||||
|
||||
{#if $page.error.postId}
|
||||
<p>Could not find post with ID: {$page.error.postId}</p>
|
||||
{/if}
|
||||
```
|
||||
|
||||
## Status Code Specific Errors
|
||||
|
||||
```svelte
|
||||
<!-- +error.svelte -->
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
</script>
|
||||
|
||||
{#if $page.status === 404}
|
||||
<h1>Page Not Found</h1>
|
||||
<p>The page you're looking for doesn't exist.</p>
|
||||
<a href="/">Go home</a>
|
||||
{:else if $page.status === 403}
|
||||
<h1>Access Denied</h1>
|
||||
<p>You don't have permission to view this page.</p>
|
||||
{:else if $page.status === 401}
|
||||
<h1>Unauthorized</h1>
|
||||
<p>Please log in to continue.</p>
|
||||
<a href="/login">Login</a>
|
||||
{:else if $page.status >= 500}
|
||||
<h1>Server Error</h1>
|
||||
<p>Something went wrong on our end. Please try again later.</p>
|
||||
{:else}
|
||||
<h1>Error {$page.status}</h1>
|
||||
<p>{$page.error.message}</p>
|
||||
{/if}
|
||||
```
|
||||
|
||||
## Common Status Codes
|
||||
|
||||
- **400** - Bad Request (malformed request)
|
||||
- **401** - Unauthorized (not logged in)
|
||||
- **403** - Forbidden (logged in but no permission)
|
||||
- **404** - Not Found (resource doesn't exist)
|
||||
- **500** - Internal Server Error (unhandled exception)
|
||||
- **503** - Service Unavailable (server overloaded/down)
|
||||
|
||||
## Error in Layout vs Page
|
||||
|
||||
**Layout error:**
|
||||
|
||||
```typescript
|
||||
// src/routes/dashboard/+layout.server.ts
|
||||
export const load = async ({ locals }) => {
|
||||
if (!locals.user) {
|
||||
throw error(401, 'Not logged in'); // Caught by +error.svelte ABOVE dashboard
|
||||
}
|
||||
return { user: locals.user };
|
||||
};
|
||||
```
|
||||
|
||||
**Page error:**
|
||||
|
||||
```typescript
|
||||
// src/routes/dashboard/settings/+page.server.ts
|
||||
export const load = async () => {
|
||||
throw error(404, 'Settings not found'); // Caught by nearest +error.svelte
|
||||
};
|
||||
```
|
||||
|
||||
## Expected Errors vs Unexpected Errors
|
||||
|
||||
**Expected (use error()):**
|
||||
|
||||
```typescript
|
||||
// User requests invalid resource
|
||||
if (!post) throw error(404, 'Post not found');
|
||||
|
||||
// User lacks permission
|
||||
if (post.authorId !== user.id) throw error(403, 'Not your post');
|
||||
```
|
||||
|
||||
**Unexpected (let it bubble):**
|
||||
|
||||
```typescript
|
||||
// Unhandled exceptions (DB connection fails, etc.)
|
||||
// Will show generic 500 error
|
||||
const posts = await db.query.posts.findMany(); // Might throw
|
||||
```
|
||||
|
||||
## Handling Errors in handleError Hook
|
||||
|
||||
```typescript
|
||||
// src/hooks.server.ts
|
||||
import type { HandleServerError } from '@sveltejs/kit';
|
||||
|
||||
export const handleError: HandleServerError = ({ error, event }) => {
|
||||
// Log to error tracking service
|
||||
console.error('Error:', error, 'Path:', event.url.pathname);
|
||||
|
||||
// Return user-friendly message (don't expose internals)
|
||||
return {
|
||||
message: 'An unexpected error occurred',
|
||||
code: error?.code ?? 'UNKNOWN',
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## Error in Load vs Error in Component
|
||||
|
||||
**Load function error (recommended):**
|
||||
|
||||
```typescript
|
||||
// +page.server.ts
|
||||
export const load = async ({ params }) => {
|
||||
const user = await getUser(params.id);
|
||||
if (!user) throw error(404, 'User not found'); // Shows +error.svelte
|
||||
return { user };
|
||||
};
|
||||
```
|
||||
|
||||
**Component error (not ideal):**
|
||||
|
||||
```svelte
|
||||
<!-- +page.svelte - Don't do this -->
|
||||
<script>
|
||||
export let data;
|
||||
|
||||
// If data.user is undefined, component just shows nothing
|
||||
// No error boundary triggered
|
||||
</script>
|
||||
|
||||
<h1>{data.user.name}</h1> <!-- Might crash if user undefined -->
|
||||
```
|
||||
|
||||
**Best practice:** Validate and throw errors in `load` functions, not
|
||||
components.
|
||||
|
||||
## Fallback Error Handling
|
||||
|
||||
Always have a root `+error.svelte`:
|
||||
|
||||
```svelte
|
||||
<!-- src/routes/+error.svelte -->
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
import { dev } from '$app/environment';
|
||||
</script>
|
||||
|
||||
<h1>Oops! Something went wrong</h1>
|
||||
|
||||
{#if dev}
|
||||
<!-- Show details in dev mode -->
|
||||
<pre>{JSON.stringify($page.error, null, 2)}</pre>
|
||||
{:else}
|
||||
<!-- Generic message in production -->
|
||||
<p>We're sorry, but something unexpected happened.</p>
|
||||
{/if}
|
||||
|
||||
<a href="/">Go home</a>
|
||||
```
|
||||
|
||||
## Testing Error Boundaries
|
||||
|
||||
```typescript
|
||||
// +page.server.ts
|
||||
export const load = async ({ url }) => {
|
||||
// Test error page in dev
|
||||
if (url.searchParams.has('test-error')) {
|
||||
throw error(500, 'Test error');
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
```
|
||||
|
||||
Visit `http://localhost:5173/page?test-error` to see error boundary.
|
||||
|
||||
## Key Takeaways
|
||||
|
||||
1. ✅ Place `+error.svelte` ABOVE failing routes
|
||||
2. ✅ Errors bubble up to nearest error boundary
|
||||
3. ✅ Always have a root `src/routes/+error.svelte`
|
||||
4. ✅ Throw errors in `load` functions, not components
|
||||
5. ✅ Use specific status codes (401, 403, 404, 500)
|
||||
6. ✅ Customize error pages per section (blog, admin, etc.)
|
||||
7. ✅ Use `handleError` hook for logging/monitoring
|
||||
8. ✅ Show detailed errors in dev, generic in production
|
||||
@@ -0,0 +1,69 @@
|
||||
# File Naming Conventions
|
||||
|
||||
## Core Files
|
||||
|
||||
| File | Purpose | Runs | Example |
|
||||
| ------------------- | --------------------- | --------------------- | --------------------------------------------- |
|
||||
| `+page.svelte` | Page component | Client & Server (SSR) | `/routes/about/+page.svelte` → `/about` |
|
||||
| `+page.ts` | Universal load | Client & Server | Data for +page.svelte |
|
||||
| `+page.server.ts` | Server load & actions | Server only | DB queries, form actions |
|
||||
| `+layout.svelte` | Layout wrapper | Client & Server | Wraps child routes |
|
||||
| `+layout.ts` | Layout universal load | Client & Server | Data for +layout.svelte |
|
||||
| `+layout.server.ts` | Layout server load | Server only | Auth, user data |
|
||||
| `+error.svelte` | Error boundary | Client & Server | Shown when error thrown |
|
||||
| `+server.ts` | API endpoint | Server only | `/routes/api/users/+server.ts` → `/api/users` |
|
||||
|
||||
## Route Parameters
|
||||
|
||||
| Pattern | Matches | Example |
|
||||
| -------------- | -------------- | ---------------------------------------------------------------- |
|
||||
| `[id]` | Single param | `/posts/[id]/+page.svelte` → `/posts/123` |
|
||||
| `[slug]` | Single param | `/blog/[slug]/+page.svelte` → `/blog/hello-world` |
|
||||
| `[[optional]]` | Optional param | `/search/[[query]]/+page.svelte` → `/search` or `/search/svelte` |
|
||||
| `[...rest]` | Rest params | `/docs/[...path]/+page.svelte` → `/docs/a/b/c` |
|
||||
|
||||
## Route Groups
|
||||
|
||||
| Pattern | Purpose | URL |
|
||||
| ------------- | ---------------------------- | ---------------------------------------------- |
|
||||
| `(group)` | Group routes (no URL impact) | `/(app)/dashboard/+page.svelte` → `/dashboard` |
|
||||
| `(marketing)` | Separate layouts | Different layout for marketing pages |
|
||||
|
||||
## Special Files
|
||||
|
||||
- `hooks.server.ts` - Server hooks (handle function, runs on every
|
||||
request)
|
||||
- `hooks.client.ts` - Client hooks (runs in browser)
|
||||
- `app.html` - HTML template
|
||||
- `service-worker.ts` - Service worker
|
||||
- `params/*.ts` - Param validators
|
||||
|
||||
## Common Patterns
|
||||
|
||||
```
|
||||
src/routes/
|
||||
├── +layout.svelte # Root layout
|
||||
├── +layout.ts # Root data
|
||||
├── +page.svelte # Homepage
|
||||
├── +error.svelte # Root error boundary
|
||||
│
|
||||
├── (app)/ # App routes (grouped)
|
||||
│ ├── +layout.svelte # App layout (auth required)
|
||||
│ ├── dashboard/+page.svelte # /dashboard
|
||||
│ └── settings/+page.svelte # /settings
|
||||
│
|
||||
├── (marketing)/ # Marketing routes (grouped)
|
||||
│ ├── +layout.svelte # Marketing layout
|
||||
│ ├── about/+page.svelte # /about
|
||||
│ └── pricing/+page.svelte # /pricing
|
||||
│
|
||||
├── blog/
|
||||
│ ├── +page.svelte # /blog (list)
|
||||
│ └── [slug]/
|
||||
│ ├── +page.svelte # /blog/post-title
|
||||
│ └── +page.server.ts # Load post data
|
||||
│
|
||||
└── api/
|
||||
└── posts/
|
||||
└── +server.ts # API: GET/POST /api/posts
|
||||
```
|
||||
@@ -0,0 +1,387 @@
|
||||
# Layout Patterns
|
||||
|
||||
## Basic Layout
|
||||
|
||||
```svelte
|
||||
<!-- src/routes/+layout.svelte -->
|
||||
<script>
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<header>Header</header>
|
||||
<main>{@render children()}</main>
|
||||
<footer>Footer</footer>
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
|
||||
- Must declare `children` in `$props()`
|
||||
- Use `{@render children()}` to render nested content
|
||||
- Root layout wraps ALL pages
|
||||
|
||||
## Nested Layouts
|
||||
|
||||
Layouts inherit from parent layouts:
|
||||
|
||||
```
|
||||
src/routes/
|
||||
├── +layout.svelte # Root layout (all pages)
|
||||
└── dashboard/
|
||||
├── +layout.svelte # Dashboard layout (dashboard pages only)
|
||||
└── +page.svelte # Uses both layouts
|
||||
```
|
||||
|
||||
**Result:** Root layout wraps dashboard layout wraps page.
|
||||
|
||||
**Rendering order:**
|
||||
|
||||
```
|
||||
Root +layout.svelte
|
||||
└─ Dashboard +layout.svelte
|
||||
└─ +page.svelte
|
||||
```
|
||||
|
||||
### Example: Nested Layout Pattern
|
||||
|
||||
```svelte
|
||||
<!-- src/routes/+layout.svelte -->
|
||||
<script>
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<div class="app">
|
||||
<nav>Global Nav</nav>
|
||||
{@render children()}
|
||||
</div>
|
||||
```
|
||||
|
||||
```svelte
|
||||
<!-- src/routes/dashboard/+layout.svelte -->
|
||||
<script>
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<div class="dashboard">
|
||||
<aside>Dashboard Sidebar</aside>
|
||||
<main>{@render children()}</main>
|
||||
</div>
|
||||
```
|
||||
|
||||
```svelte
|
||||
<!-- src/routes/dashboard/+page.svelte --><h1>Dashboard Home</h1>
|
||||
```
|
||||
|
||||
**Rendered HTML structure:**
|
||||
|
||||
```html
|
||||
<div class="app">
|
||||
<nav>Global Nav</nav>
|
||||
<div class="dashboard">
|
||||
<aside>Dashboard Sidebar</aside>
|
||||
<main>
|
||||
<h1>Dashboard Home</h1>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Layout Groups
|
||||
|
||||
Use `(groups)` to organize without affecting URLs:
|
||||
|
||||
```
|
||||
src/routes/
|
||||
├── (marketing)/
|
||||
│ ├── +layout.svelte # Marketing layout
|
||||
│ ├── about/+page.svelte # /about (uses marketing layout)
|
||||
│ └── pricing/+page.svelte # /pricing (uses marketing layout)
|
||||
│
|
||||
└── (app)/
|
||||
├── +layout.svelte # App layout
|
||||
├── dashboard/+page.svelte # /dashboard (uses app layout)
|
||||
└── settings/+page.svelte # /settings (uses app layout)
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
|
||||
- Parentheses make groups invisible in URLs
|
||||
- `/about` route, NOT `/(marketing)/about`
|
||||
- Different layouts for different sections
|
||||
- Useful for authentication boundaries
|
||||
|
||||
### Example: Marketing vs App Layouts
|
||||
|
||||
```svelte
|
||||
<!-- src/routes/(marketing)/+layout.svelte -->
|
||||
<script>
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<header class="marketing-header">
|
||||
<nav>
|
||||
<a href="/">Home</a>
|
||||
<a href="/about">About</a>
|
||||
<a href="/login">Login</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{@render children()}
|
||||
|
||||
<footer>© 2024 Company</footer>
|
||||
```
|
||||
|
||||
```svelte
|
||||
<!-- src/routes/(app)/+layout.svelte -->
|
||||
<script>
|
||||
let { children, data } = $props();
|
||||
</script>
|
||||
|
||||
<header class="app-header">
|
||||
<nav>
|
||||
<a href="/dashboard">Dashboard</a>
|
||||
<a href="/settings">Settings</a>
|
||||
<span>Welcome, {data.user.name}</span>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{@render children()}
|
||||
```
|
||||
|
||||
## Reset Layout
|
||||
|
||||
Use `@` in filename to break layout inheritance:
|
||||
|
||||
**NOT RECOMMENDED** - This feature is deprecated in SvelteKit 2+.
|
||||
|
||||
Instead, use layout groups to create separate hierarchies:
|
||||
|
||||
```
|
||||
src/routes/
|
||||
├── +layout.svelte # Root layout (for most pages)
|
||||
├── (app)/
|
||||
│ └── +layout.svelte # App layout
|
||||
└── (public)/
|
||||
└── +layout.svelte # Public layout (completely separate)
|
||||
```
|
||||
|
||||
## Layout with Data Loading
|
||||
|
||||
```svelte
|
||||
<!-- src/routes/+layout.server.ts -->
|
||||
export const load = async ({ locals }) => {
|
||||
// Available to all child routes
|
||||
return {
|
||||
user: locals.user,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
```svelte
|
||||
<!-- src/routes/+layout.svelte -->
|
||||
<script>
|
||||
let { children, data } = $props();
|
||||
</script>
|
||||
|
||||
<header>
|
||||
{#if data.user}
|
||||
<span>Welcome, {data.user.name}</span>
|
||||
{:else}
|
||||
<a href="/login">Login</a>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{@render children()}
|
||||
```
|
||||
|
||||
## Conditional Layout Content
|
||||
|
||||
```svelte
|
||||
<!-- +layout.svelte -->
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{#if !$page.url.pathname.startsWith('/admin')}
|
||||
<header>Public Header</header>
|
||||
{/if}
|
||||
|
||||
{@render children()}
|
||||
|
||||
{#if !$page.url.pathname.includes('/checkout')}
|
||||
<footer>Footer</footer>
|
||||
{/if}
|
||||
```
|
||||
|
||||
**Better approach:** Use layout groups instead of conditionals.
|
||||
|
||||
## Protected Layouts
|
||||
|
||||
```typescript
|
||||
// src/routes/(app)/+layout.server.ts
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const load = async ({ locals }) => {
|
||||
if (!locals.user) {
|
||||
throw redirect(303, '/login');
|
||||
}
|
||||
|
||||
return {
|
||||
user: locals.user,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
All routes under `(app)` group now require authentication.
|
||||
|
||||
## Sharing Layout State
|
||||
|
||||
```svelte
|
||||
<!-- src/routes/+layout.svelte -->
|
||||
<script>
|
||||
import { setContext } from 'svelte';
|
||||
let { children, data } = $props();
|
||||
|
||||
// Share state with all descendant components
|
||||
setContext('user', data.user);
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
```
|
||||
|
||||
```svelte
|
||||
<!-- Any child component -->
|
||||
<script>
|
||||
import { getContext } from 'svelte';
|
||||
const user = getContext('user');
|
||||
</script>
|
||||
|
||||
<p>Hello, {user.name}</p>
|
||||
```
|
||||
|
||||
## Layout Slot Props (Snippets)
|
||||
|
||||
```svelte
|
||||
<!-- src/routes/dashboard/+layout.svelte -->
|
||||
<script>
|
||||
let { children, header } = $props();
|
||||
</script>
|
||||
|
||||
<div class="dashboard">
|
||||
<aside>Sidebar</aside>
|
||||
<div class="content">
|
||||
{#if header}
|
||||
<header>{@render header()}</header>
|
||||
{/if}
|
||||
<main>{@render children()}</main>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
```svelte
|
||||
<!-- src/routes/dashboard/+page.svelte -->
|
||||
{#snippet header()}
|
||||
<h1>Custom Dashboard Header</h1>
|
||||
{/snippet}
|
||||
|
||||
<p>Dashboard content</p>
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern 1: Auth Boundary
|
||||
|
||||
```
|
||||
src/routes/
|
||||
├── +layout.svelte # Root (no auth)
|
||||
├── (public)/
|
||||
│ ├── +layout.svelte # Public layout (landing pages)
|
||||
│ ├── +page.svelte # /
|
||||
│ └── about/+page.svelte # /about
|
||||
└── (protected)/
|
||||
├── +layout.server.ts # Check auth, redirect if not logged in
|
||||
├── +layout.svelte # Protected layout (app UI)
|
||||
├── dashboard/+page.svelte # /dashboard
|
||||
└── settings/+page.svelte # /settings
|
||||
```
|
||||
|
||||
### Pattern 2: Multi-tenant with Shared Root
|
||||
|
||||
```
|
||||
src/routes/
|
||||
├── +layout.svelte # Root layout (shared)
|
||||
├── admin/
|
||||
│ ├── +layout.svelte # Admin layout
|
||||
│ └── users/+page.svelte # /admin/users
|
||||
└── client/
|
||||
├── +layout.svelte # Client layout
|
||||
└── projects/+page.svelte # /client/projects
|
||||
```
|
||||
|
||||
### Pattern 3: Progressive Enhancement
|
||||
|
||||
```svelte
|
||||
<!-- src/routes/+layout.svelte -->
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
import { navigating } from '$app/stores';
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<div class="app">
|
||||
{#if $navigating}
|
||||
<div class="loading-bar"></div>
|
||||
{/if}
|
||||
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.loading-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: blue;
|
||||
animation: loading 1s ease-in-out;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
## Layout Best Practices
|
||||
|
||||
1. ✅ Keep root layout minimal (shared across ALL pages)
|
||||
2. ✅ Use layout groups for separate sections
|
||||
3. ✅ Load shared data in layout's load function
|
||||
4. ✅ Use context for sharing state with descendants
|
||||
5. ✅ Avoid too many nested layouts (max 2-3 levels)
|
||||
6. ✅ Don't put auth logic in root layout (use groups)
|
||||
7. ✅ Remember: layouts share data DOWN, not UP
|
||||
8. ❌ Avoid conditionals in layouts (use groups instead)
|
||||
|
||||
## Debugging Layouts
|
||||
|
||||
```svelte
|
||||
<!-- src/routes/+layout.svelte -->
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
let { children } = $props();
|
||||
|
||||
$effect(() => {
|
||||
console.log('Current route:', $page.url.pathname);
|
||||
console.log('Layout data:', $page.data);
|
||||
});
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
```
|
||||
|
||||
## Key Takeaways
|
||||
|
||||
- Layouts wrap all child routes
|
||||
- Nested layouts create hierarchy (root → section → page)
|
||||
- Groups `(name)` organize without affecting URLs
|
||||
- Load data in `+layout.server.ts` to share with children
|
||||
- Use context to share reactive state
|
||||
- Keep layouts simple and focused
|
||||
@@ -0,0 +1,92 @@
|
||||
# SSR & Hydration
|
||||
|
||||
## The Problem
|
||||
|
||||
SvelteKit runs on server (SSR) then hydrates in browser. Code using
|
||||
browser APIs (`window`, `document`, `localStorage`) fails on server.
|
||||
|
||||
## Solution: Check for Browser
|
||||
|
||||
```typescript
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
// In load function
|
||||
export const load = async () => {
|
||||
const theme = browser ? localStorage.getItem('theme') : 'light';
|
||||
return { theme };
|
||||
};
|
||||
```
|
||||
|
||||
```svelte
|
||||
<!-- In component -->
|
||||
<script>
|
||||
import { browser } from '$app/environment';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let data = $state(null);
|
||||
|
||||
// Option 1: Use browser check
|
||||
if (browser) {
|
||||
data = localStorage.getItem('data');
|
||||
}
|
||||
|
||||
// Option 2: Use onMount (only runs in browser)
|
||||
onMount(() => {
|
||||
data = localStorage.getItem('data');
|
||||
});
|
||||
|
||||
// Option 3: Use $effect with browser check
|
||||
$effect(() => {
|
||||
if (browser) {
|
||||
data = localStorage.getItem('data');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### ❌ Using window Without Check
|
||||
|
||||
```typescript
|
||||
// WRONG - fails on server
|
||||
export const load = async () => {
|
||||
const width = window.innerWidth; // ERROR on server
|
||||
return { width };
|
||||
};
|
||||
|
||||
// RIGHT
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export const load = async () => {
|
||||
const width = browser ? window.innerWidth : 1024;
|
||||
return { width };
|
||||
};
|
||||
```
|
||||
|
||||
### ❌ Accessing DOM in Load
|
||||
|
||||
```typescript
|
||||
// WRONG
|
||||
export const load = async () => {
|
||||
const el = document.getElementById('root'); // ERROR on server
|
||||
};
|
||||
|
||||
// RIGHT - Do DOM stuff in onMount
|
||||
export const load = async () => {
|
||||
return {};
|
||||
};
|
||||
// Then in component:
|
||||
onMount(() => {
|
||||
const el = document.getElementById('root');
|
||||
});
|
||||
```
|
||||
|
||||
## Disable SSR (Not Recommended)
|
||||
|
||||
```typescript
|
||||
// +page.ts
|
||||
export const ssr = false; // Disables SSR for this page
|
||||
```
|
||||
|
||||
Only use when absolutely necessary (e.g., heavy Canvas/WebGL).
|
||||
@@ -0,0 +1,150 @@
|
||||
# svelte:boundary Component
|
||||
|
||||
> Available in Svelte 5.3+
|
||||
|
||||
## Two Purposes
|
||||
|
||||
1. **Error boundaries** - catch rendering errors
|
||||
2. **Pending UI** - show loading state while `await` resolves
|
||||
|
||||
## Basic Error Boundary
|
||||
|
||||
```svelte
|
||||
<svelte:boundary onerror={(e, reset) => console.error(e)}>
|
||||
<RiskyComponent />
|
||||
|
||||
{#snippet failed(error, reset)}
|
||||
<p>Error: {error.message}</p>
|
||||
<button onclick={reset}>Try again</button>
|
||||
{/snippet}
|
||||
</svelte:boundary>
|
||||
```
|
||||
|
||||
## Pending UI (Loading States)
|
||||
|
||||
> **⚠️ Known Bug:** `<svelte:boundary>` + `{@const await}` causes
|
||||
> infinite navigation loops during client-side page transitions when
|
||||
> pages share async queries. See
|
||||
> [sveltejs/svelte#17717](https://github.com/sveltejs/svelte/issues/17717).
|
||||
> Use `{#await}` blocks until this is fixed.
|
||||
|
||||
With `experimental.async: true` (Svelte 5.36+), `{@const await}` is
|
||||
possible but **not safe for pages with shared queries or navigation**:
|
||||
|
||||
```svelte
|
||||
<!-- ⚠️ Causes navigation loops - use {#await} instead -->
|
||||
<svelte:boundary>
|
||||
{#snippet pending()}
|
||||
<LoadingSpinner />
|
||||
{/snippet}
|
||||
|
||||
{@const data = await loadData()}
|
||||
<DataView {data} />
|
||||
</svelte:boundary>
|
||||
```
|
||||
|
||||
**Safe alternative:**
|
||||
|
||||
```svelte
|
||||
{#await loadData()}
|
||||
<LoadingSpinner />
|
||||
{:then data}
|
||||
<DataView {data} />
|
||||
{:catch error}
|
||||
<p>Error: {error.message}</p>
|
||||
{/await}
|
||||
```
|
||||
|
||||
## Combined Error + Pending
|
||||
|
||||
Use `svelte:boundary` for **error catching only**, with `{#await}` for
|
||||
async data:
|
||||
|
||||
```svelte
|
||||
<svelte:boundary onerror={logError}>
|
||||
{#snippet failed(error, reset)}
|
||||
<p>Failed to load user</p>
|
||||
<button onclick={reset}>Retry</button>
|
||||
{/snippet}
|
||||
|
||||
{#await fetchUser()}
|
||||
<p>Loading user...</p>
|
||||
{:then user}
|
||||
<UserProfile {user} />
|
||||
{:catch error}
|
||||
<p>Error: {error.message}</p>
|
||||
{/await}
|
||||
</svelte:boundary>
|
||||
```
|
||||
|
||||
## What Gets Caught
|
||||
|
||||
**Caught:**
|
||||
|
||||
- Errors during rendering
|
||||
- Errors in `$effect`
|
||||
|
||||
**NOT Caught:**
|
||||
|
||||
- Event handler errors (`onclick`, etc.)
|
||||
- Errors after `setTimeout`
|
||||
- Async errors outside boundary's await
|
||||
|
||||
## vs +error.svelte
|
||||
|
||||
| Feature | svelte:boundary | +error.svelte |
|
||||
| -------- | ----------------------- | ------------- |
|
||||
| Scope | Component subtree | Route segment |
|
||||
| Reset | Built-in reset function | Navigate away |
|
||||
| Pending | Yes (pending snippet) | No |
|
||||
| Use case | Component-level | Page-level |
|
||||
|
||||
## Error Tracking Integration
|
||||
|
||||
```svelte
|
||||
<svelte:boundary
|
||||
onerror={(error, reset) => {
|
||||
// Send to Sentry, LogRocket, etc.
|
||||
errorTracker.captureException(error);
|
||||
}}
|
||||
>
|
||||
<App />
|
||||
|
||||
{#snippet failed(error, reset)}
|
||||
<ErrorFallback {error} {reset} />
|
||||
{/snippet}
|
||||
</svelte:boundary>
|
||||
```
|
||||
|
||||
## Nested Boundaries
|
||||
|
||||
Inner boundary catches first:
|
||||
|
||||
```svelte
|
||||
<svelte:boundary>
|
||||
<!-- Outer fallback -->
|
||||
{#snippet failed(e)}
|
||||
<p>Outer caught: {e.message}</p>
|
||||
{/snippet}
|
||||
|
||||
<svelte:boundary>
|
||||
<!-- Inner fallback -->
|
||||
{#snippet failed(e)}
|
||||
<p>Inner caught: {e.message}</p>
|
||||
{/snippet}
|
||||
|
||||
<ComponentThatMightFail />
|
||||
</svelte:boundary>
|
||||
</svelte:boundary>
|
||||
```
|
||||
|
||||
## Key Points
|
||||
|
||||
- Use `svelte:boundary` for component-level error isolation
|
||||
- Use `+error.svelte` for route-level error pages
|
||||
- `pending` snippet shows only on **initial** load (not on refresh - no flicker)
|
||||
- `failed` snippet replaces content on error
|
||||
- `reset` function lets users retry
|
||||
- Errors in event handlers are NOT caught
|
||||
- Requires `experimental.async: true` in svelte.config.js for `{@const await}`
|
||||
- **⚠️ Bug:** `{@const await}` + navigation causes infinite loops ([#17717](https://github.com/sveltejs/svelte/issues/17717)) — use `{#await}` instead
|
||||
@@ -0,0 +1,270 @@
|
||||
---
|
||||
name: typescript-best-practices
|
||||
description: Provides TypeScript patterns for type-first development, making illegal states unrepresentable, exhaustive handling, and runtime validation. Must use when reading or writing TypeScript/JavaScript files.
|
||||
---
|
||||
|
||||
# TypeScript Best Practices
|
||||
|
||||
## Pair with React Best Practices
|
||||
|
||||
When working with React components (`.tsx`, `.jsx` files or `@react` imports), always load `react-best-practices` alongside this skill. This skill covers TypeScript fundamentals; React-specific patterns (effects, hooks, refs, component design) are in the dedicated React skill.
|
||||
|
||||
## Type-First Development
|
||||
|
||||
Types define the contract before implementation. Follow this workflow:
|
||||
|
||||
1. **Define the data model** - types, interfaces, and schemas first
|
||||
2. **Define function signatures** - input/output types before logic
|
||||
3. **Implement to satisfy types** - let the compiler guide completeness
|
||||
4. **Validate at boundaries** - runtime checks where data enters the system
|
||||
|
||||
### Make Illegal States Unrepresentable
|
||||
|
||||
Use the type system to prevent invalid states at compile time.
|
||||
|
||||
**Discriminated unions for mutually exclusive states:**
|
||||
```ts
|
||||
// Good: only valid combinations possible
|
||||
type RequestState<T> =
|
||||
| { status: 'idle' }
|
||||
| { status: 'loading' }
|
||||
| { status: 'success'; data: T }
|
||||
| { status: 'error'; error: Error };
|
||||
|
||||
// Bad: allows invalid combinations like { loading: true, error: Error }
|
||||
type RequestState<T> = {
|
||||
loading: boolean;
|
||||
data?: T;
|
||||
error?: Error;
|
||||
};
|
||||
```
|
||||
|
||||
**Branded types for domain primitives:**
|
||||
```ts
|
||||
type UserId = string & { readonly __brand: 'UserId' };
|
||||
type OrderId = string & { readonly __brand: 'OrderId' };
|
||||
|
||||
// Compiler prevents passing OrderId where UserId expected
|
||||
function getUser(id: UserId): Promise<User> { /* ... */ }
|
||||
|
||||
function createUserId(id: string): UserId {
|
||||
return id as UserId;
|
||||
}
|
||||
```
|
||||
|
||||
**Const assertions for literal unions:**
|
||||
```ts
|
||||
const ROLES = ['admin', 'user', 'guest'] as const;
|
||||
type Role = typeof ROLES[number]; // 'admin' | 'user' | 'guest'
|
||||
|
||||
// Array and type stay in sync automatically
|
||||
function isValidRole(role: string): role is Role {
|
||||
return ROLES.includes(role as Role);
|
||||
}
|
||||
```
|
||||
|
||||
**Required vs optional fields - be explicit:**
|
||||
```ts
|
||||
// Creation: some fields required
|
||||
type CreateUser = {
|
||||
email: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
// Update: all fields optional
|
||||
type UpdateUser = Partial<CreateUser>;
|
||||
|
||||
// Database row: all fields present
|
||||
type User = CreateUser & {
|
||||
id: UserId;
|
||||
createdAt: Date;
|
||||
};
|
||||
```
|
||||
|
||||
## Module Structure
|
||||
|
||||
Prefer smaller, focused files: one component, hook, or utility per file. Split when a file handles multiple concerns or exceeds ~200 lines. Colocate tests with implementation (`foo.test.ts` alongside `foo.ts`). Group related files by feature rather than by type.
|
||||
|
||||
## Functional Patterns
|
||||
|
||||
- Prefer `const` over `let`; use `readonly` and `Readonly<T>` for immutable data.
|
||||
- Use `array.map/filter/reduce` over `for` loops; chain transformations in pipelines.
|
||||
- Write pure functions for business logic; isolate side effects in dedicated modules.
|
||||
- Avoid mutating function parameters; return new objects/arrays instead.
|
||||
|
||||
## Instructions
|
||||
|
||||
- Enable `strict` mode; model data with interfaces and types. Strong typing catches bugs at compile time.
|
||||
- Every code path returns a value or throws; use exhaustive `switch` with `never` checks in default. Unhandled cases become compile errors.
|
||||
- Propagate errors with context; catching requires re-throwing or returning a meaningful result. Hidden failures delay debugging.
|
||||
- Handle edge cases explicitly: empty arrays, null/undefined inputs, boundary values. Defensive checks prevent runtime surprises.
|
||||
- Use `await` for async calls; wrap external calls with contextual error messages. Unhandled rejections crash Node processes.
|
||||
- Add or update focused tests when changing logic; test behavior, not implementation details.
|
||||
|
||||
## Examples
|
||||
|
||||
Explicit failure for unimplemented logic:
|
||||
```ts
|
||||
export function buildWidget(widgetType: string): never {
|
||||
throw new Error(`buildWidget not implemented for type: ${widgetType}`);
|
||||
}
|
||||
```
|
||||
|
||||
Exhaustive switch with never check:
|
||||
```ts
|
||||
type Status = "active" | "inactive";
|
||||
|
||||
export function processStatus(status: Status): string {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return "processing";
|
||||
case "inactive":
|
||||
return "skipped";
|
||||
default: {
|
||||
const _exhaustive: never = status;
|
||||
throw new Error(`unhandled status: ${_exhaustive}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Wrap external calls with context:
|
||||
```ts
|
||||
export async function fetchWidget(id: string): Promise<Widget> {
|
||||
const response = await fetch(`/api/widgets/${id}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`fetch widget ${id} failed: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
```
|
||||
|
||||
Debug logging with namespaced logger:
|
||||
```ts
|
||||
import debug from "debug";
|
||||
|
||||
const log = debug("myapp:widgets");
|
||||
|
||||
export function createWidget(name: string): Widget {
|
||||
log("creating widget: %s", name);
|
||||
const widget = { id: crypto.randomUUID(), name };
|
||||
log("created widget: %s", widget.id);
|
||||
return widget;
|
||||
}
|
||||
```
|
||||
|
||||
## Runtime Validation with Zod
|
||||
|
||||
- Define schemas as single source of truth; infer TypeScript types with `z.infer<>`. Avoid duplicating types and schemas.
|
||||
- Use `safeParse` for user input where failure is expected; use `parse` at trust boundaries where invalid data is a bug.
|
||||
- Compose schemas with `.extend()`, `.pick()`, `.omit()`, `.merge()` for DRY definitions.
|
||||
- Add `.transform()` for data normalization at parse time (trim strings, parse dates).
|
||||
- Include descriptive error messages; use `.refine()` for custom validation logic.
|
||||
|
||||
### Examples
|
||||
|
||||
Schema as source of truth with type inference:
|
||||
```ts
|
||||
import { z } from "zod";
|
||||
|
||||
const UserSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
email: z.string().email(),
|
||||
name: z.string().min(1),
|
||||
createdAt: z.string().transform((s) => new Date(s)),
|
||||
});
|
||||
|
||||
type User = z.infer<typeof UserSchema>;
|
||||
```
|
||||
|
||||
Return parse results to callers (never swallow errors):
|
||||
```ts
|
||||
import { z, SafeParseReturnType } from "zod";
|
||||
|
||||
export function parseUserInput(raw: unknown): SafeParseReturnType<unknown, User> {
|
||||
return UserSchema.safeParse(raw);
|
||||
}
|
||||
|
||||
// Caller handles both success and error:
|
||||
const result = parseUserInput(formData);
|
||||
if (!result.success) {
|
||||
setErrors(result.error.flatten().fieldErrors);
|
||||
return;
|
||||
}
|
||||
await submitUser(result.data);
|
||||
```
|
||||
|
||||
Strict parsing at trust boundaries:
|
||||
```ts
|
||||
export async function fetchUser(id: string): Promise<User> {
|
||||
const response = await fetch(`/api/users/${id}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`fetch user ${id} failed: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
return UserSchema.parse(data); // throws if API contract violated
|
||||
}
|
||||
```
|
||||
|
||||
Schema composition:
|
||||
```ts
|
||||
const CreateUserSchema = UserSchema.omit({ id: true, createdAt: true });
|
||||
const UpdateUserSchema = CreateUserSchema.partial();
|
||||
const UserWithPostsSchema = UserSchema.extend({
|
||||
posts: z.array(PostSchema),
|
||||
});
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
- Load config from environment variables at startup; validate with Zod before use. Invalid config should crash immediately.
|
||||
- Define a typed config object as single source of truth; avoid accessing `process.env` throughout the codebase.
|
||||
- Use sensible defaults for development; require explicit values for production secrets.
|
||||
|
||||
### Examples
|
||||
|
||||
Typed config with Zod validation:
|
||||
```ts
|
||||
import { z } from "zod";
|
||||
|
||||
const ConfigSchema = z.object({
|
||||
PORT: z.coerce.number().default(3000),
|
||||
DATABASE_URL: z.string().url(),
|
||||
API_KEY: z.string().min(1),
|
||||
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
|
||||
});
|
||||
|
||||
export const config = ConfigSchema.parse(process.env);
|
||||
```
|
||||
|
||||
Access config values (not process.env directly):
|
||||
```ts
|
||||
import { config } from "./config";
|
||||
|
||||
const server = app.listen(config.PORT);
|
||||
const db = connect(config.DATABASE_URL);
|
||||
```
|
||||
|
||||
## Optional: type-fest
|
||||
|
||||
For advanced type utilities beyond TypeScript builtins, consider [type-fest](https://github.com/sindresorhus/type-fest):
|
||||
|
||||
- `Opaque<T, Token>` - cleaner branded types than manual `& { __brand }` pattern
|
||||
- `PartialDeep<T>` - recursive partial for nested objects
|
||||
- `ReadonlyDeep<T>` - recursive readonly for immutable data
|
||||
- `LiteralUnion<Literals, Fallback>` - literals with autocomplete + string fallback
|
||||
- `SetRequired<T, K>` / `SetOptional<T, K>` - targeted field modifications
|
||||
- `Simplify<T>` - flatten complex intersection types in IDE tooltips
|
||||
|
||||
```ts
|
||||
import type { Opaque, PartialDeep, SetRequired } from 'type-fest';
|
||||
|
||||
// Branded type (cleaner than manual approach)
|
||||
type UserId = Opaque<string, 'UserId'>;
|
||||
|
||||
// Deep partial for patch operations
|
||||
type UserPatch = PartialDeep<User>;
|
||||
|
||||
// Make specific fields required
|
||||
type UserWithEmail = SetRequired<Partial<User>, 'email'>;
|
||||
```
|
||||
Reference in New Issue
Block a user