Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 42 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,8 @@ Note: The demo has its own `node_modules` and uses Zod for some validation examp

### Core Files

- `src/index.ts` - Public exports: `createSvState`, validator builders, types (`Snapshot`, `EffectContext`, `SnapshotFunction`, `SvStateOptions`, `Validator`)
- `src/state.svelte.ts` - Main `createSvState<T, V, P>()` function with snapshot/undo system
- `src/index.ts` - Public exports: `createSvState`, validator builders, types (`Snapshot`, `EffectContext`, `SnapshotFunction`, `SvStateOptions`, `Validator`, `AsyncValidator`, `AsyncValidatorFunction`, `AsyncErrors`)
- `src/state.svelte.ts` - Main `createSvState<T, V, P>()` function with snapshot/undo system and async validation
- `src/proxy.ts` - `ChangeProxy` deep reactive proxy implementation
- `src/validators.ts` - Fluent validator builders (string, number, array, date)

Expand All @@ -97,26 +97,35 @@ const { data, execute, state, rollback, reset } = createSvState(init, actuators?
- `rollback(steps?)` - Undo N steps (default 1), restores state and triggers validation
- `reset()` - Return to initial snapshot, triggers validation
- `state` - Object containing reactive stores:
- `errors: Readable<V | undefined>` - Validation errors
- `hasErrors: Readable<boolean>` - Whether any validation errors exist
- `errors: Readable<V | undefined>` - Validation errors (sync)
- `hasErrors: Readable<boolean>` - Whether any sync validation errors exist
- `isDirty: Readable<boolean>` - Whether state has been modified
- `actionInProgress: Readable<boolean>` - Action execution status
- `actionError: Readable<Error | undefined>` - Last action error
- `snapshots: Readable<Snapshot<T>[]>` - Snapshot history for undo
- `asyncErrors: Readable<AsyncErrors>` - Async validation errors (keyed by property path)
- `hasAsyncErrors: Readable<boolean>` - Whether any async validation errors exist
- `asyncValidating: Readable<string[]>` - Property paths currently being validated
- `hasCombinedErrors: Readable<boolean>` - Whether any sync OR async errors exist

**Actuators:**

- `validator?: (source: T) => V` - Validation function returning error structure
- `validator?: (source: T) => V` - Sync validation function returning error structure
- `effect?: (context: EffectContext<T>) => void` - Side effect receiving context object with `snapshot` function
- `action?: (params?: P) => Promise<void> | void` - Async action to execute
- `actionCompleted?: (error?: unknown) => void | Promise<void>` - Callback after action completes (can be async)
- `asyncValidator?: AsyncValidator<T>` - Async validators keyed by property path (see Async Validation System)

**Options:**

- `resetDirtyOnAction: boolean` (default: `true`) - Reset `isDirty` after successful action
- `debounceValidation: number` (default: `0`) - Debounce validation by N ms (0 = `queueMicrotask`)
- `debounceValidation: number` (default: `0`) - Debounce sync validation by N ms (0 = `queueMicrotask`)
- `allowConcurrentActions: boolean` (default: `false`) - Ignore `execute()` if action in progress
- `persistActionError: boolean` (default: `false`) - Keep action errors until next action
- `debounceAsyncValidation: number` (default: `300`) - Debounce async validation by N ms
- `runAsyncValidationOnInit: boolean` (default: `false`) - Run async validators when state is created
- `clearAsyncErrorsOnChange: boolean` (default: `true`) - Clear async error for a path when that property changes
- `maxConcurrentAsyncValidations: number` (default: `4`) - Maximum concurrent async validators running simultaneously

### Snapshot/Undo System

Expand All @@ -140,6 +149,33 @@ effect: ({ snapshot, property }) => {
- Successful action execution resets snapshots with current state as new initial
- `rollback()` and `reset()` trigger validation after restoring state

### Async Validation System

Async validators are defined per property path and receive an `AbortSignal` for cancellation:

```typescript
type AsyncValidatorFunction<T> = (value: unknown, source: T, signal: AbortSignal) => Promise<string>;

type AsyncValidator<T> = {
[propertyPath: string]: AsyncValidatorFunction<T>;
};
```

**Key behaviors:**

- Async validators are keyed by dot-notation property paths (e.g., `"email"`, `"user.username"`)
- When a property changes, matching async validators are scheduled after `debounceAsyncValidation` ms
- If sync validation fails for a property path, async validation is skipped for that path
- Changing a property cancels any pending async validation for that path
- `rollback()` and `reset()` cancel all async validations and clear async errors
- The `maxConcurrentAsyncValidations` option limits how many async validators run simultaneously; additional validators are queued

**Matching rules for property paths:**

- Exact match: validator for `"email"` triggers when `email` changes
- Parent triggers child: validator for `"user.email"` triggers when `user` changes
- Child triggers parent: validator for `"user"` triggers when `user.email` changes

### Deep Clone System (src/state.svelte.ts)

The `deepClone` function preserves object prototypes using `Object.create(Object.getPrototypeOf(object))`. This allows state objects to include methods that operate on `this`:
Expand Down
127 changes: 109 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ const customer = $state({

- 🔍 **Detects changes** at any nesting level (`customer.billing.bankAccount.iban`)
- ✅ **Validates** with a structure that mirrors your data
- 🌐 **Async validation** for server-side checks (username availability, email verification)
- ⚡ **Fires effects** when any property changes (with full context)
- ⏪ **Snapshots & undo** for complex editing workflows
- 🎯 **Tracks dirty state** automatically
Expand Down Expand Up @@ -162,6 +163,63 @@ const {
- ⚡ First-error-wins: `getError()` returns the first failure
- 🔀 Conditional validation: `requiredIf(condition)` on all validators

#### Async Validation

For server-side validation (checking username availability, email verification, etc.), svstate supports async validators that run after sync validation passes:

```typescript
import { createSvState, stringValidator, type AsyncValidator } from 'svstate';

type UserForm = { username: string; email: string };

const asyncValidators: AsyncValidator<UserForm> = {
username: async (value, source, signal) => {
// Skip if empty (let sync validation handle required)
if (!value) return '';

const response = await fetch(`/api/check-username?name=${value}`, { signal });
const { available } = await response.json();
return available ? '' : 'Username already taken';
},
email: async (value, source, signal) => {
if (!value) return '';

const response = await fetch(`/api/check-email?email=${value}`, { signal });
const { valid } = await response.json();
return valid ? '' : 'Email not deliverable';
}
};

const {
data,
state: { errors, asyncErrors, asyncValidating, hasAsyncErrors, hasCombinedErrors }
} = createSvState(
{ username: '', email: '' },
{
validator: (source) => ({
username: stringValidator(source.username).required().minLength(3).getError(),
email: stringValidator(source.email).required().email().getError()
}),
asyncValidator: asyncValidators
},
{ debounceAsyncValidation: 500 }
);

// In template:
// {#if $asyncValidating.includes('username')}Checking...{/if}
// {#if $asyncErrors.username}{$asyncErrors.username}{/if}
// <button disabled={$hasCombinedErrors}>Submit</button>
```

**Key features:**

- 🌐 Async validators receive `AbortSignal` for automatic cancellation
- ⏱️ Debounced by default (300ms) to avoid excessive API calls
- 🔄 Auto-cancels on property change or new validation
- 🚫 Skipped if sync validation fails for the same path
- 🎯 `asyncValidating` shows which paths are currently checking
- 🔒 `maxConcurrentAsyncValidations` limits parallel requests (default: 4)

---

### 2️⃣ Effect — React to Every Change
Expand Down Expand Up @@ -317,23 +375,39 @@ const { data } = createSvState(formData, actuators, {
// Reset isDirty after successful action (default: true)
resetDirtyOnAction: true,

// Debounce validation in ms (default: 0 = microtask)
// Debounce sync validation in ms (default: 0 = microtask)
debounceValidation: 300,

// Allow concurrent action executions (default: false)
allowConcurrentActions: false,

// Keep actionError until next action (default: false)
persistActionError: false
persistActionError: false,

// Debounce async validation in ms (default: 300)
debounceAsyncValidation: 500,

// Run async validators on state creation (default: false)
runAsyncValidationOnInit: false,

// Clear async error when property changes (default: true)
clearAsyncErrorsOnChange: true,

// Max concurrent async validators (default: 4)
maxConcurrentAsyncValidations: 4
});
```

| Option | Default | Description |
| ------------------------ | ------- | ---------------------------------------- |
| `resetDirtyOnAction` | `true` | Clear dirty flag after successful action |
| `debounceValidation` | `0` | Delay validation (0 = next microtask) |
| `allowConcurrentActions` | `false` | Block execute() while action runs |
| `persistActionError` | `false` | Clear error on next change or action |
| Option | Default | Description |
| ------------------------------- | ------- | ------------------------------------------ |
| `resetDirtyOnAction` | `true` | Clear dirty flag after successful action |
| `debounceValidation` | `0` | Delay sync validation (0 = next microtask) |
| `allowConcurrentActions` | `false` | Block execute() while action runs |
| `persistActionError` | `false` | Clear error on next change or action |
| `debounceAsyncValidation` | `300` | Delay async validation in ms |
| `runAsyncValidationOnInit` | `false` | Run async validators on creation |
| `clearAsyncErrorsOnChange` | `true` | Clear async error when property changes |
| `maxConcurrentAsyncValidations` | `4` | Max concurrent async validators |

---

Expand Down Expand Up @@ -779,12 +853,16 @@ Creates a supercharged state object.
| `execute(params?)` | `(P?) => Promise<void>` | Run the configured action |
| `rollback(steps?)` | `(n?: number) => void` | Undo N changes (default: 1) |
| `reset()` | `() => void` | Return to initial state |
| `state.errors` | `Readable<V>` | Validation errors store |
| `state.hasErrors` | `Readable<boolean>` | Quick error check |
| `state.errors` | `Readable<V>` | Sync validation errors store |
| `state.hasErrors` | `Readable<boolean>` | Has sync errors? |
| `state.isDirty` | `Readable<boolean>` | Has state changed? |
| `state.actionInProgress` | `Readable<boolean>` | Is action running? |
| `state.actionError` | `Readable<Error>` | Last action error |
| `state.snapshots` | `Readable<Snapshot[]>` | Undo history |
| `state.asyncErrors` | `Readable<AsyncErrors>` | Async validation errors (keyed by path) |
| `state.hasAsyncErrors` | `Readable<boolean>` | Has async errors? |
| `state.asyncValidating` | `Readable<string[]>` | Paths currently validating |
| `state.hasCombinedErrors` | `Readable<boolean>` | Has sync OR async errors? |

### Built-in Validators

Expand All @@ -804,16 +882,28 @@ String validators support optional preprocessing (`'trim'`, `'normalize'`, `'upp
svstate exports TypeScript types to help you write type-safe external validator and effect functions. This is useful when you want to define these functions outside the `createSvState` call or reuse them across multiple state instances.

```typescript
import type { Validator, EffectContext, Snapshot, SnapshotFunction, SvStateOptions } from 'svstate';
import type {
Validator,
EffectContext,
Snapshot,
SnapshotFunction,
SvStateOptions,
AsyncValidator,
AsyncValidatorFunction,
AsyncErrors
} from 'svstate';
```

| Type | Description |
| ------------------ | --------------------------------------------------------------------------------------------------- |
| `Validator` | Nested object type for validation errors — leaf values are error strings (empty = valid) |
| `EffectContext<T>` | Context object passed to effect callbacks: `{ snapshot, target, property, currentValue, oldValue }` |
| `SnapshotFunction` | Type for the `snapshot(title, replace?)` function used in effects |
| `Snapshot<T>` | Shape of a snapshot entry: `{ title: string; data: T }` |
| `SvStateOptions` | Configuration options type for `createSvState` |
| Type | Description |
| --------------------------- | --------------------------------------------------------------------------------------------------- |
| `Validator` | Nested object type for validation errors — leaf values are error strings (empty = valid) |
| `EffectContext<T>` | Context object passed to effect callbacks: `{ snapshot, target, property, currentValue, oldValue }` |
| `SnapshotFunction` | Type for the `snapshot(title, replace?)` function used in effects |
| `Snapshot<T>` | Shape of a snapshot entry: `{ title: string; data: T }` |
| `SvStateOptions` | Configuration options type for `createSvState` |
| `AsyncValidator<T>` | Object mapping property paths to async validator functions |
| `AsyncValidatorFunction<T>` | Async function: `(value, source, signal) => Promise<string>` |
| `AsyncErrors` | Object mapping property paths to error strings |

**Example: External validator and effect functions**

Expand Down Expand Up @@ -860,6 +950,7 @@ const { data, state } = createSvState<UserData, UserErrors, object>(
| Deep nested objects | ⚠️ Manual tracking | ✅ Automatic |
| Property change events | ❌ Not available | ✅ Full context |
| Structured validation | ❌ DIY | ✅ Mirrors data |
| Async validation | ❌ DIY | ✅ Built-in |
| Undo/Redo | ❌ DIY | ✅ Built-in |
| Dirty tracking | ❌ DIY | ✅ Automatic |
| Action loading states | ❌ DIY | ✅ Built-in |
Expand Down
Loading