@signaltree/core 7.1.5 → 7.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +392 -0
- package/dist/constants.js +6 -0
- package/dist/deep-equal.js +41 -0
- package/dist/enhancers/batching/batching.js +230 -0
- package/dist/enhancers/devtools/devtools.js +318 -0
- package/dist/enhancers/effects/effects.js +66 -0
- package/dist/enhancers/entities/entities.js +7 -0
- package/dist/enhancers/index.js +72 -0
- package/dist/enhancers/memoization/memoization.js +420 -0
- package/dist/enhancers/presets/lib/presets.js +27 -0
- package/dist/enhancers/serialization/constants.js +15 -0
- package/dist/enhancers/serialization/serialization.js +656 -0
- package/dist/enhancers/time-travel/time-travel.js +283 -0
- package/dist/enhancers/time-travel/utils.js +11 -0
- package/dist/enhancers/utils/copy-tree-properties.js +20 -0
- package/dist/index.js +27 -0
- package/dist/is-built-in-object.js +23 -0
- package/dist/lib/async-helpers.js +77 -0
- package/dist/lib/constants.js +56 -0
- package/dist/lib/edit-session.js +84 -0
- package/dist/lib/entity-signal.js +544 -0
- package/dist/lib/internals/batch-scope.js +8 -0
- package/dist/lib/internals/derived-types.js +6 -0
- package/dist/lib/internals/materialize-markers.js +72 -0
- package/dist/lib/internals/merge-derived.js +59 -0
- package/dist/lib/markers/derived.js +6 -0
- package/dist/lib/markers/entity-map.js +41 -0
- package/dist/lib/markers/form.js +310 -0
- package/dist/lib/markers/status.js +71 -0
- package/dist/lib/markers/stored.js +213 -0
- package/dist/lib/memory/memory-manager.js +164 -0
- package/dist/lib/path-notifier.js +178 -0
- package/dist/lib/presets.js +20 -0
- package/dist/lib/security/security-validator.js +121 -0
- package/dist/lib/signal-tree.js +416 -0
- package/dist/lib/types.js +3 -0
- package/dist/lib/utils.js +264 -0
- package/dist/lru-cache.js +64 -0
- package/dist/parse-path.js +13 -0
- package/package.json +1 -1
- package/src/enhancers/batching/batching.d.ts +10 -0
- package/src/enhancers/batching/batching.types.d.ts +1 -0
- package/src/enhancers/batching/index.d.ts +1 -0
- package/src/enhancers/batching/test-setup.d.ts +3 -0
- package/src/enhancers/devtools/devtools.d.ts +68 -0
- package/src/enhancers/devtools/devtools.types.d.ts +1 -0
- package/src/enhancers/devtools/index.d.ts +1 -0
- package/src/enhancers/devtools/test-setup.d.ts +3 -0
- package/src/enhancers/effects/effects.d.ts +9 -0
- package/src/enhancers/effects/effects.types.d.ts +1 -0
- package/src/enhancers/effects/index.d.ts +1 -0
- package/src/enhancers/entities/entities.d.ts +7 -0
- package/src/enhancers/entities/entities.types.d.ts +1 -0
- package/src/enhancers/entities/index.d.ts +1 -0
- package/src/enhancers/entities/test-setup.d.ts +3 -0
- package/src/enhancers/index.d.ts +3 -0
- package/src/enhancers/memoization/index.d.ts +1 -0
- package/src/enhancers/memoization/memoization.d.ts +54 -0
- package/src/enhancers/memoization/memoization.types.d.ts +1 -0
- package/src/enhancers/memoization/test-setup.d.ts +3 -0
- package/src/enhancers/presets/index.d.ts +1 -0
- package/src/enhancers/presets/lib/presets.d.ts +8 -0
- package/src/enhancers/serialization/constants.d.ts +14 -0
- package/src/enhancers/serialization/index.d.ts +2 -0
- package/src/enhancers/serialization/serialization.d.ts +68 -0
- package/src/enhancers/serialization/test-setup.d.ts +3 -0
- package/src/enhancers/test-helpers/types-equals.d.ts +2 -0
- package/src/enhancers/time-travel/index.d.ts +1 -0
- package/src/enhancers/time-travel/test-setup.d.ts +3 -0
- package/src/enhancers/time-travel/time-travel.d.ts +10 -0
- package/src/enhancers/time-travel/time-travel.types.d.ts +1 -0
- package/src/enhancers/time-travel/utils.d.ts +2 -0
- package/src/enhancers/types.d.ts +1 -0
- package/src/enhancers/typing/helpers-types.d.ts +2 -0
- package/src/enhancers/utils/copy-tree-properties.d.ts +1 -0
- package/src/index.d.ts +27 -0
- package/src/lib/async-helpers.d.ts +8 -0
- package/src/lib/constants.d.ts +41 -0
- package/src/lib/dev-proxy.d.ts +3 -0
- package/src/lib/edit-session.d.ts +21 -0
- package/src/lib/entity-signal.d.ts +1 -0
- package/src/lib/internals/batch-scope.d.ts +3 -0
- package/src/lib/internals/builder-types.d.ts +13 -0
- package/src/lib/internals/derived-types.d.ts +19 -0
- package/src/lib/internals/materialize-markers.d.ts +5 -0
- package/src/lib/internals/merge-derived.d.ts +4 -0
- package/src/lib/markers/derived.d.ts +9 -0
- package/src/lib/markers/entity-map.d.ts +19 -0
- package/src/lib/markers/form.d.ts +86 -0
- package/src/lib/markers/index.d.ts +4 -0
- package/src/lib/markers/status.d.ts +32 -0
- package/src/lib/markers/stored.d.ts +35 -0
- package/src/lib/memory/memory-manager.d.ts +30 -0
- package/src/lib/path-notifier.d.ts +34 -0
- package/src/lib/performance/diff-engine.d.ts +33 -0
- package/src/lib/performance/path-index.d.ts +25 -0
- package/src/lib/performance/update-engine.d.ts +32 -0
- package/src/lib/presets.d.ts +34 -0
- package/src/lib/security/security-validator.d.ts +33 -0
- package/src/lib/signal-tree.d.ts +6 -0
- package/src/lib/types.d.ts +301 -0
- package/src/lib/utils.d.ts +25 -0
package/README.md
CHANGED
|
@@ -1029,6 +1029,398 @@ export const myDerived = derivedFrom<AppTreeBase>()(($) => ({
|
|
|
1029
1029
|
|
|
1030
1030
|
**Key point**: `derivedFrom` is **only needed for functions defined in separate files**. Inline functions automatically inherit types from the chain. Note the curried syntax: `derivedFrom<TreeType>()(fn)` - this allows TypeScript to infer the return type while you specify the tree type.
|
|
1031
1031
|
|
|
1032
|
+
## Built-in Markers
|
|
1033
|
+
|
|
1034
|
+
SignalTree provides four built-in markers that handle common state patterns Angular doesn't provide out of the box. All markers are **self-registering** and **tree-shakeable** - only the markers you use are included in your bundle.
|
|
1035
|
+
|
|
1036
|
+
### 9) `entityMap<E, K>()` - Normalized Collections
|
|
1037
|
+
|
|
1038
|
+
Creates a normalized entity collection with O(1) lookups by ID. Includes chainable `.computed()` for derived slices.
|
|
1039
|
+
|
|
1040
|
+
```typescript
|
|
1041
|
+
import { signalTree, entityMap } from '@signaltree/core';
|
|
1042
|
+
|
|
1043
|
+
interface Product {
|
|
1044
|
+
id: number;
|
|
1045
|
+
name: string;
|
|
1046
|
+
category: string;
|
|
1047
|
+
price: number;
|
|
1048
|
+
inStock: boolean;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
const tree = signalTree({
|
|
1052
|
+
products: entityMap<Product, number>()
|
|
1053
|
+
.computed('electronics', (all) => all.filter((p) => p.category === 'electronics'))
|
|
1054
|
+
.computed('inStock', (all) => all.filter((p) => p.inStock))
|
|
1055
|
+
.computed('totalValue', (all) => all.reduce((sum, p) => sum + p.price, 0)),
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
// EntitySignal API
|
|
1059
|
+
tree.$.products.setMany([
|
|
1060
|
+
{ id: 1, name: 'Laptop', category: 'electronics', price: 999, inStock: true },
|
|
1061
|
+
{ id: 2, name: 'Chair', category: 'furniture', price: 199, inStock: false },
|
|
1062
|
+
]);
|
|
1063
|
+
|
|
1064
|
+
tree.$.products.all(); // Signal<Product[]> - all entities
|
|
1065
|
+
tree.$.products.byId(1); // Signal<Product> | undefined
|
|
1066
|
+
tree.$.products.ids(); // Signal<number[]>
|
|
1067
|
+
tree.$.products.count(); // Signal<number>
|
|
1068
|
+
|
|
1069
|
+
// Computed slices (reactive, type-safe)
|
|
1070
|
+
tree.$.products.electronics(); // Signal<Product[]> - auto-updates
|
|
1071
|
+
tree.$.products.inStock(); // Signal<Product[]>
|
|
1072
|
+
tree.$.products.totalValue(); // Signal<number>
|
|
1073
|
+
|
|
1074
|
+
// CRUD operations
|
|
1075
|
+
tree.$.products.upsertOne({ id: 1, name: 'Updated', category: 'electronics', price: 899, inStock: true });
|
|
1076
|
+
tree.$.products.upsertMany([...]);
|
|
1077
|
+
tree.$.products.removeOne(1);
|
|
1078
|
+
tree.$.products.removeMany([1, 2]);
|
|
1079
|
+
tree.$.products.clear();
|
|
1080
|
+
```
|
|
1081
|
+
|
|
1082
|
+
#### Custom ID Selection
|
|
1083
|
+
|
|
1084
|
+
```typescript
|
|
1085
|
+
interface User {
|
|
1086
|
+
odataId: string; // Not named 'id'
|
|
1087
|
+
email: string;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
const tree = signalTree({
|
|
1091
|
+
users: entityMap<User, string>(),
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
// Specify selectId when upserting
|
|
1095
|
+
tree.$.users.upsertOne(user, { selectId: (u) => u.odataId });
|
|
1096
|
+
```
|
|
1097
|
+
|
|
1098
|
+
### 10) `status()` - Manual Async State
|
|
1099
|
+
|
|
1100
|
+
Creates a status signal for manual async state management with type-safe error handling.
|
|
1101
|
+
|
|
1102
|
+
```typescript
|
|
1103
|
+
import { signalTree, status, LoadingState } from '@signaltree/core';
|
|
1104
|
+
|
|
1105
|
+
interface ApiError {
|
|
1106
|
+
code: number;
|
|
1107
|
+
message: string;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
const tree = signalTree({
|
|
1111
|
+
users: {
|
|
1112
|
+
data: [] as User[],
|
|
1113
|
+
loadStatus: status<ApiError>(), // Generic error type
|
|
1114
|
+
},
|
|
1115
|
+
});
|
|
1116
|
+
|
|
1117
|
+
// Status API
|
|
1118
|
+
tree.$.users.loadStatus.state(); // Signal<LoadingState>
|
|
1119
|
+
tree.$.users.loadStatus.error(); // Signal<ApiError | null>
|
|
1120
|
+
|
|
1121
|
+
// Convenience signals
|
|
1122
|
+
tree.$.users.loadStatus.isNotLoaded(); // Signal<boolean>
|
|
1123
|
+
tree.$.users.loadStatus.isLoading(); // Signal<boolean>
|
|
1124
|
+
tree.$.users.loadStatus.isLoaded(); // Signal<boolean>
|
|
1125
|
+
tree.$.users.loadStatus.isError(); // Signal<boolean>
|
|
1126
|
+
|
|
1127
|
+
// Update methods
|
|
1128
|
+
tree.$.users.loadStatus.setLoading();
|
|
1129
|
+
tree.$.users.loadStatus.setLoaded();
|
|
1130
|
+
tree.$.users.loadStatus.setError({ code: 404, message: 'Not found' });
|
|
1131
|
+
tree.$.users.loadStatus.reset();
|
|
1132
|
+
|
|
1133
|
+
// LoadingState enum
|
|
1134
|
+
LoadingState.NotLoaded; // 'not-loaded'
|
|
1135
|
+
LoadingState.Loading; // 'loading'
|
|
1136
|
+
LoadingState.Loaded; // 'loaded'
|
|
1137
|
+
LoadingState.Error; // 'error'
|
|
1138
|
+
```
|
|
1139
|
+
|
|
1140
|
+
### 11) `stored(key, default, options?)` - localStorage Persistence
|
|
1141
|
+
|
|
1142
|
+
Auto-syncs state to localStorage with versioning and migration support.
|
|
1143
|
+
|
|
1144
|
+
```typescript
|
|
1145
|
+
import { signalTree, stored, createStorageKeys, clearStoragePrefix } from '@signaltree/core';
|
|
1146
|
+
|
|
1147
|
+
// Basic usage
|
|
1148
|
+
const tree = signalTree({
|
|
1149
|
+
theme: stored('app-theme', 'light' as 'light' | 'dark'),
|
|
1150
|
+
lastViewedId: stored('last-viewed', null as number | null),
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
// Auto-loads from localStorage on init
|
|
1154
|
+
// Auto-saves on every .set() or .update()
|
|
1155
|
+
tree.$.theme.set('dark'); // Saved to localStorage immediately
|
|
1156
|
+
|
|
1157
|
+
// StoredSignal API
|
|
1158
|
+
tree.$.theme(); // Get current value
|
|
1159
|
+
tree.$.theme.set('light'); // Set and persist
|
|
1160
|
+
tree.$.theme.clear(); // Remove from storage, reset to default
|
|
1161
|
+
tree.$.theme.reload(); // Force reload from storage
|
|
1162
|
+
```
|
|
1163
|
+
|
|
1164
|
+
#### Versioning and Migrations
|
|
1165
|
+
|
|
1166
|
+
```typescript
|
|
1167
|
+
interface SettingsV1 {
|
|
1168
|
+
darkMode: boolean;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
interface SettingsV2 {
|
|
1172
|
+
theme: 'light' | 'dark' | 'system';
|
|
1173
|
+
fontSize: number;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
const tree = signalTree({
|
|
1177
|
+
settings: stored<SettingsV2>(
|
|
1178
|
+
'user-settings',
|
|
1179
|
+
{ theme: 'light', fontSize: 14 },
|
|
1180
|
+
{
|
|
1181
|
+
version: 2,
|
|
1182
|
+
migrate: (oldData, oldVersion) => {
|
|
1183
|
+
if (oldVersion === 1) {
|
|
1184
|
+
// Migrate from V1 to V2
|
|
1185
|
+
const v1 = oldData as SettingsV1;
|
|
1186
|
+
return {
|
|
1187
|
+
theme: v1.darkMode ? 'dark' : 'light',
|
|
1188
|
+
fontSize: 14, // New field with default
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
1191
|
+
return oldData as SettingsV2;
|
|
1192
|
+
},
|
|
1193
|
+
clearOnMigrationFailure: true, // Clear storage if migration fails
|
|
1194
|
+
}
|
|
1195
|
+
),
|
|
1196
|
+
});
|
|
1197
|
+
```
|
|
1198
|
+
|
|
1199
|
+
#### Type-Safe Storage Keys
|
|
1200
|
+
|
|
1201
|
+
```typescript
|
|
1202
|
+
// Create namespaced storage keys
|
|
1203
|
+
const STORAGE = createStorageKeys('myApp', {
|
|
1204
|
+
theme: 'theme',
|
|
1205
|
+
user: {
|
|
1206
|
+
settings: 'settings',
|
|
1207
|
+
preferences: 'prefs',
|
|
1208
|
+
},
|
|
1209
|
+
} as const);
|
|
1210
|
+
|
|
1211
|
+
// STORAGE.theme = "myApp:theme"
|
|
1212
|
+
// STORAGE.user.settings = "myApp:user:settings"
|
|
1213
|
+
|
|
1214
|
+
const tree = signalTree({
|
|
1215
|
+
theme: stored(STORAGE.theme, 'light'),
|
|
1216
|
+
settings: stored(STORAGE.user.settings, {}),
|
|
1217
|
+
});
|
|
1218
|
+
|
|
1219
|
+
// Clear all app storage (e.g., on logout)
|
|
1220
|
+
clearStoragePrefix('myApp');
|
|
1221
|
+
```
|
|
1222
|
+
|
|
1223
|
+
#### Advanced Options
|
|
1224
|
+
|
|
1225
|
+
```typescript
|
|
1226
|
+
stored('key', defaultValue, {
|
|
1227
|
+
version: 1, // Schema version
|
|
1228
|
+
migrate: (old, ver) => migrated, // Migration function
|
|
1229
|
+
debounceMs: 100, // Write debounce (default: 100)
|
|
1230
|
+
storage: sessionStorage, // Custom storage backend
|
|
1231
|
+
serialize: (v) => JSON.stringify(v), // Custom serializer
|
|
1232
|
+
deserialize: (s) => JSON.parse(s), // Custom deserializer
|
|
1233
|
+
clearOnMigrationFailure: false, // Clear on failed migration
|
|
1234
|
+
});
|
|
1235
|
+
```
|
|
1236
|
+
|
|
1237
|
+
### 12) `form(config)` - Tree-Integrated Forms
|
|
1238
|
+
|
|
1239
|
+
Creates forms with validation, wizard navigation, and persistence that live inside SignalTree.
|
|
1240
|
+
|
|
1241
|
+
```typescript
|
|
1242
|
+
import { signalTree, form, validators } from '@signaltree/core';
|
|
1243
|
+
|
|
1244
|
+
interface ContactForm {
|
|
1245
|
+
name: string;
|
|
1246
|
+
email: string;
|
|
1247
|
+
phone: string;
|
|
1248
|
+
message: string;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
const tree = signalTree({
|
|
1252
|
+
contact: form<ContactForm>({
|
|
1253
|
+
initial: { name: '', email: '', phone: '', message: '' },
|
|
1254
|
+
validators: {
|
|
1255
|
+
name: validators.required('Name is required'),
|
|
1256
|
+
email: [validators.required('Email is required'), validators.email('Invalid email format')],
|
|
1257
|
+
phone: validators.pattern(/^\+?[\d\s-]+$/, 'Invalid phone number'),
|
|
1258
|
+
message: validators.minLength(10, 'Message must be at least 10 characters'),
|
|
1259
|
+
},
|
|
1260
|
+
}),
|
|
1261
|
+
});
|
|
1262
|
+
|
|
1263
|
+
// FormSignal API - Field access via $
|
|
1264
|
+
tree.$.contact.$.name(); // Get field value
|
|
1265
|
+
tree.$.contact.$.name.set('Jane'); // Set field value
|
|
1266
|
+
tree.$.contact.$.email();
|
|
1267
|
+
|
|
1268
|
+
// Form-level operations
|
|
1269
|
+
tree.$.contact(); // Get all values: ContactForm
|
|
1270
|
+
tree.$.contact.set({ name: 'Jane', email: 'jane@example.com', phone: '', message: '' });
|
|
1271
|
+
tree.$.contact.patch({ name: 'Updated' }); // Partial update
|
|
1272
|
+
tree.$.contact.reset(); // Reset to initial values
|
|
1273
|
+
tree.$.contact.clear(); // Clear all values
|
|
1274
|
+
|
|
1275
|
+
// Validation signals
|
|
1276
|
+
tree.$.contact.valid(); // Signal<boolean>
|
|
1277
|
+
tree.$.contact.dirty(); // Signal<boolean>
|
|
1278
|
+
tree.$.contact.submitting(); // Signal<boolean>
|
|
1279
|
+
tree.$.contact.touched(); // Signal<Record<keyof T, boolean>>
|
|
1280
|
+
tree.$.contact.errors(); // Signal<Partial<Record<keyof T, string>>>
|
|
1281
|
+
tree.$.contact.errorList(); // Signal<string[]>
|
|
1282
|
+
|
|
1283
|
+
// Validation methods
|
|
1284
|
+
await tree.$.contact.validate(); // Validate all fields
|
|
1285
|
+
await tree.$.contact.validateField('email');
|
|
1286
|
+
tree.$.contact.touch('name'); // Mark field as touched
|
|
1287
|
+
tree.$.contact.touchAll(); // Mark all fields as touched
|
|
1288
|
+
```
|
|
1289
|
+
|
|
1290
|
+
#### Built-in Validators
|
|
1291
|
+
|
|
1292
|
+
```typescript
|
|
1293
|
+
import { validators } from '@signaltree/core';
|
|
1294
|
+
|
|
1295
|
+
validators.required('Field is required')
|
|
1296
|
+
validators.minLength(5, 'Min 5 characters')
|
|
1297
|
+
validators.maxLength(100, 'Max 100 characters')
|
|
1298
|
+
validators.min(0, 'Must be positive')
|
|
1299
|
+
validators.max(100, 'Max 100')
|
|
1300
|
+
validators.email('Invalid email')
|
|
1301
|
+
validators.pattern(/regex/, 'Invalid format')
|
|
1302
|
+
|
|
1303
|
+
// Compose multiple validators
|
|
1304
|
+
validators: {
|
|
1305
|
+
password: [
|
|
1306
|
+
validators.required('Password is required'),
|
|
1307
|
+
validators.minLength(8, 'Min 8 characters'),
|
|
1308
|
+
validators.pattern(/[A-Z]/, 'Must contain uppercase'),
|
|
1309
|
+
validators.pattern(/[0-9]/, 'Must contain number'),
|
|
1310
|
+
],
|
|
1311
|
+
}
|
|
1312
|
+
```
|
|
1313
|
+
|
|
1314
|
+
#### Wizard Navigation
|
|
1315
|
+
|
|
1316
|
+
```typescript
|
|
1317
|
+
const tree = signalTree({
|
|
1318
|
+
listing: form<ListingDraft>({
|
|
1319
|
+
initial: { title: '', description: '', photos: [], price: null, location: '' },
|
|
1320
|
+
validators: {
|
|
1321
|
+
title: validators.required('Title is required'),
|
|
1322
|
+
price: [validators.required('Price required'), validators.min(0, 'Must be positive')],
|
|
1323
|
+
location: validators.required('Location required'),
|
|
1324
|
+
},
|
|
1325
|
+
wizard: {
|
|
1326
|
+
steps: ['details', 'media', 'pricing', 'review'],
|
|
1327
|
+
stepFields: {
|
|
1328
|
+
details: ['title', 'description'],
|
|
1329
|
+
media: ['photos'],
|
|
1330
|
+
pricing: ['price'],
|
|
1331
|
+
review: ['location'],
|
|
1332
|
+
},
|
|
1333
|
+
},
|
|
1334
|
+
}),
|
|
1335
|
+
});
|
|
1336
|
+
|
|
1337
|
+
// Wizard API
|
|
1338
|
+
const wizard = tree.$.listing.wizard!;
|
|
1339
|
+
|
|
1340
|
+
wizard.currentStep(); // Signal<number> - 0-based index
|
|
1341
|
+
wizard.stepName(); // Signal<string> - current step name
|
|
1342
|
+
wizard.steps(); // Signal<string[]> - all step names
|
|
1343
|
+
wizard.canNext(); // Signal<boolean>
|
|
1344
|
+
wizard.canPrev(); // Signal<boolean>
|
|
1345
|
+
wizard.isFirstStep(); // Signal<boolean>
|
|
1346
|
+
wizard.isLastStep(); // Signal<boolean>
|
|
1347
|
+
|
|
1348
|
+
// Navigation (validates current step before proceeding)
|
|
1349
|
+
await wizard.next(); // Returns false if validation fails
|
|
1350
|
+
wizard.prev();
|
|
1351
|
+
await wizard.goTo(2); // Jump to step by index
|
|
1352
|
+
await wizard.goTo('pricing'); // Jump to step by name
|
|
1353
|
+
wizard.reset(); // Go back to first step
|
|
1354
|
+
```
|
|
1355
|
+
|
|
1356
|
+
#### Form Persistence
|
|
1357
|
+
|
|
1358
|
+
```typescript
|
|
1359
|
+
const tree = signalTree({
|
|
1360
|
+
draft: form<EmailDraft>({
|
|
1361
|
+
initial: { subject: '', body: '', to: '' },
|
|
1362
|
+
persist: 'email-draft', // localStorage key
|
|
1363
|
+
persistDebounceMs: 500, // Debounce writes (default: 500ms)
|
|
1364
|
+
validators: {
|
|
1365
|
+
subject: validators.required('Subject required'),
|
|
1366
|
+
to: validators.email('Invalid email'),
|
|
1367
|
+
},
|
|
1368
|
+
}),
|
|
1369
|
+
});
|
|
1370
|
+
|
|
1371
|
+
// Form auto-saves to localStorage
|
|
1372
|
+
// On page reload, draft is restored automatically
|
|
1373
|
+
```
|
|
1374
|
+
|
|
1375
|
+
#### Async Validators
|
|
1376
|
+
|
|
1377
|
+
```typescript
|
|
1378
|
+
const tree = signalTree({
|
|
1379
|
+
registration: form<RegistrationForm>({
|
|
1380
|
+
initial: { username: '', email: '' },
|
|
1381
|
+
validators: {
|
|
1382
|
+
username: validators.minLength(3, 'Min 3 characters'),
|
|
1383
|
+
},
|
|
1384
|
+
asyncValidators: {
|
|
1385
|
+
username: async (value) => {
|
|
1386
|
+
const taken = await api.checkUsername(value);
|
|
1387
|
+
return taken ? 'Username already taken' : null;
|
|
1388
|
+
},
|
|
1389
|
+
email: async (value) => {
|
|
1390
|
+
const exists = await api.checkEmail(value);
|
|
1391
|
+
return exists ? 'Email already registered' : null;
|
|
1392
|
+
},
|
|
1393
|
+
},
|
|
1394
|
+
}),
|
|
1395
|
+
});
|
|
1396
|
+
```
|
|
1397
|
+
|
|
1398
|
+
#### Form Submission
|
|
1399
|
+
|
|
1400
|
+
```typescript
|
|
1401
|
+
async function handleSubmit() {
|
|
1402
|
+
const contactForm = tree.$.contact;
|
|
1403
|
+
|
|
1404
|
+
// Validate all fields first
|
|
1405
|
+
contactForm.touchAll();
|
|
1406
|
+
const isValid = await contactForm.validate();
|
|
1407
|
+
|
|
1408
|
+
if (!isValid) return;
|
|
1409
|
+
|
|
1410
|
+
// Set submitting state
|
|
1411
|
+
contactForm.setSubmitting(true);
|
|
1412
|
+
|
|
1413
|
+
try {
|
|
1414
|
+
await api.submit(contactForm());
|
|
1415
|
+
contactForm.reset();
|
|
1416
|
+
} catch (error) {
|
|
1417
|
+
// Handle error
|
|
1418
|
+
} finally {
|
|
1419
|
+
contactForm.setSubmitting(false);
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
```
|
|
1423
|
+
|
|
1032
1424
|
## Error handling examples
|
|
1033
1425
|
|
|
1034
1426
|
### Manual async error handling
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
function deepEqual(a, b) {
|
|
2
|
+
if (a === b) return true;
|
|
3
|
+
if (a == null || b == null) return a === b;
|
|
4
|
+
const typeA = typeof a;
|
|
5
|
+
const typeB = typeof b;
|
|
6
|
+
if (typeA !== typeB) return false;
|
|
7
|
+
if (typeA !== 'object') return false;
|
|
8
|
+
if (a instanceof Date && b instanceof Date) {
|
|
9
|
+
return a.getTime() === b.getTime();
|
|
10
|
+
}
|
|
11
|
+
if (a instanceof RegExp && b instanceof RegExp) {
|
|
12
|
+
return a.source === b.source && a.flags === b.flags;
|
|
13
|
+
}
|
|
14
|
+
if (a instanceof Map && b instanceof Map) {
|
|
15
|
+
if (a.size !== b.size) return false;
|
|
16
|
+
for (const [key, value] of a) {
|
|
17
|
+
if (!b.has(key) || !deepEqual(value, b.get(key))) return false;
|
|
18
|
+
}
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
if (a instanceof Set && b instanceof Set) {
|
|
22
|
+
if (a.size !== b.size) return false;
|
|
23
|
+
for (const value of a) {
|
|
24
|
+
if (!b.has(value)) return false;
|
|
25
|
+
}
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
if (Array.isArray(a)) {
|
|
29
|
+
if (!Array.isArray(b) || a.length !== b.length) return false;
|
|
30
|
+
return a.every((item, index) => deepEqual(item, b[index]));
|
|
31
|
+
}
|
|
32
|
+
if (Array.isArray(b)) return false;
|
|
33
|
+
const objA = a;
|
|
34
|
+
const objB = b;
|
|
35
|
+
const keysA = Object.keys(objA);
|
|
36
|
+
const keysB = Object.keys(objB);
|
|
37
|
+
if (keysA.length !== keysB.length) return false;
|
|
38
|
+
return keysA.every(key => key in objB && deepEqual(objA[key], objB[key]));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export { deepEqual };
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { copyTreeProperties } from '../utils/copy-tree-properties.js';
|
|
2
|
+
|
|
3
|
+
function batching(config = {}) {
|
|
4
|
+
const enabled = config.enabled ?? true;
|
|
5
|
+
const notificationDelayMs = config.notificationDelayMs ?? 0;
|
|
6
|
+
return tree => {
|
|
7
|
+
if (!enabled) {
|
|
8
|
+
const passthrough = {
|
|
9
|
+
batch: fn => fn(),
|
|
10
|
+
coalesce: fn => fn(),
|
|
11
|
+
hasPendingNotifications: () => false,
|
|
12
|
+
flushNotifications: () => {}
|
|
13
|
+
};
|
|
14
|
+
const enhanced = tree;
|
|
15
|
+
Object.assign(enhanced, passthrough);
|
|
16
|
+
enhanced.batchUpdate = updater => {
|
|
17
|
+
if (typeof tree.batchUpdate === 'function') {
|
|
18
|
+
tree.batchUpdate(updater);
|
|
19
|
+
} else {
|
|
20
|
+
updater(tree());
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
return enhanced;
|
|
24
|
+
}
|
|
25
|
+
let notificationPending = false;
|
|
26
|
+
let notificationTimeoutId;
|
|
27
|
+
let inBatch = false;
|
|
28
|
+
let inCoalesce = false;
|
|
29
|
+
const coalescedUpdates = new Map();
|
|
30
|
+
const scheduleNotification = () => {
|
|
31
|
+
if (notificationPending) return;
|
|
32
|
+
notificationPending = true;
|
|
33
|
+
if (notificationDelayMs > 0) {
|
|
34
|
+
notificationTimeoutId = setTimeout(flushNotificationsInternal, notificationDelayMs);
|
|
35
|
+
} else {
|
|
36
|
+
queueMicrotask(flushNotificationsInternal);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
const flushNotificationsInternal = () => {
|
|
40
|
+
if (!notificationPending) return;
|
|
41
|
+
notificationPending = false;
|
|
42
|
+
if (notificationTimeoutId !== undefined) {
|
|
43
|
+
clearTimeout(notificationTimeoutId);
|
|
44
|
+
notificationTimeoutId = undefined;
|
|
45
|
+
}
|
|
46
|
+
if (tree.__notifyChangeDetection) {
|
|
47
|
+
tree.__notifyChangeDetection();
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
const flushCoalescedUpdates = () => {
|
|
51
|
+
const updates = Array.from(coalescedUpdates.values());
|
|
52
|
+
coalescedUpdates.clear();
|
|
53
|
+
updates.forEach(fn => {
|
|
54
|
+
try {
|
|
55
|
+
fn();
|
|
56
|
+
} catch (e) {
|
|
57
|
+
console.error('[SignalTree] Error in coalesced update:', e);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
const wrapSignalSetters = (node, path = '') => {
|
|
62
|
+
if (!node || typeof node !== 'object') return;
|
|
63
|
+
if (typeof node.set === 'function' && !node.__batchingWrapped) {
|
|
64
|
+
const originalSet = node.set.bind(node);
|
|
65
|
+
node.set = value => {
|
|
66
|
+
if (inCoalesce) {
|
|
67
|
+
coalescedUpdates.set(path, () => originalSet(value));
|
|
68
|
+
} else {
|
|
69
|
+
originalSet(value);
|
|
70
|
+
}
|
|
71
|
+
if (!inBatch) {
|
|
72
|
+
scheduleNotification();
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
node.__batchingWrapped = true;
|
|
76
|
+
}
|
|
77
|
+
if (typeof node.update === 'function' && !node.__batchingUpdateWrapped) {
|
|
78
|
+
const originalUpdate = node.update.bind(node);
|
|
79
|
+
node.update = updater => {
|
|
80
|
+
if (inCoalesce) {
|
|
81
|
+
coalescedUpdates.set(`${path}:update:${Date.now()}`, () => originalUpdate(updater));
|
|
82
|
+
} else {
|
|
83
|
+
originalUpdate(updater);
|
|
84
|
+
}
|
|
85
|
+
if (!inBatch) {
|
|
86
|
+
scheduleNotification();
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
node.__batchingUpdateWrapped = true;
|
|
90
|
+
}
|
|
91
|
+
for (const key of Object.keys(node)) {
|
|
92
|
+
if (key.startsWith('_') || key === 'set' || key === 'update') continue;
|
|
93
|
+
const child = node[key];
|
|
94
|
+
if (child && typeof child === 'object') {
|
|
95
|
+
wrapSignalSetters(child, path ? `${path}.${key}` : key);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
if (tree.$) {
|
|
100
|
+
wrapSignalSetters(tree.$);
|
|
101
|
+
}
|
|
102
|
+
const batchingMethods = {
|
|
103
|
+
batch(fn) {
|
|
104
|
+
const wasBatching = inBatch;
|
|
105
|
+
inBatch = true;
|
|
106
|
+
try {
|
|
107
|
+
fn();
|
|
108
|
+
} finally {
|
|
109
|
+
inBatch = wasBatching;
|
|
110
|
+
if (!inBatch) {
|
|
111
|
+
scheduleNotification();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
coalesce(fn) {
|
|
116
|
+
const wasCoalescing = inCoalesce;
|
|
117
|
+
const wasBatching = inBatch;
|
|
118
|
+
inCoalesce = true;
|
|
119
|
+
inBatch = true;
|
|
120
|
+
try {
|
|
121
|
+
fn();
|
|
122
|
+
} finally {
|
|
123
|
+
inCoalesce = wasCoalescing;
|
|
124
|
+
inBatch = wasBatching;
|
|
125
|
+
if (!wasCoalescing) {
|
|
126
|
+
flushCoalescedUpdates();
|
|
127
|
+
}
|
|
128
|
+
if (!inBatch) {
|
|
129
|
+
scheduleNotification();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
hasPendingNotifications() {
|
|
134
|
+
return notificationPending;
|
|
135
|
+
},
|
|
136
|
+
flushNotifications() {
|
|
137
|
+
flushNotificationsInternal();
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
const originalTreeCall = tree.bind(tree);
|
|
141
|
+
const enhancedTree = function (...args) {
|
|
142
|
+
if (args.length === 0) {
|
|
143
|
+
return originalTreeCall();
|
|
144
|
+
} else {
|
|
145
|
+
if (args.length === 1) {
|
|
146
|
+
const arg = args[0];
|
|
147
|
+
if (typeof arg === 'function') {
|
|
148
|
+
originalTreeCall(arg);
|
|
149
|
+
} else {
|
|
150
|
+
originalTreeCall(arg);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (!inBatch) {
|
|
154
|
+
scheduleNotification();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
Object.setPrototypeOf(enhancedTree, Object.getPrototypeOf(tree));
|
|
159
|
+
Object.assign(enhancedTree, tree);
|
|
160
|
+
try {
|
|
161
|
+
copyTreeProperties(tree, enhancedTree);
|
|
162
|
+
} catch {}
|
|
163
|
+
Object.defineProperty(enhancedTree, 'with', {
|
|
164
|
+
value: function (enhancer) {
|
|
165
|
+
if (typeof enhancer !== 'function') {
|
|
166
|
+
throw new Error('Enhancer must be a function');
|
|
167
|
+
}
|
|
168
|
+
return enhancer(enhancedTree);
|
|
169
|
+
},
|
|
170
|
+
writable: false,
|
|
171
|
+
enumerable: false,
|
|
172
|
+
configurable: true
|
|
173
|
+
});
|
|
174
|
+
if ('state' in tree) {
|
|
175
|
+
Object.defineProperty(enhancedTree, 'state', {
|
|
176
|
+
value: tree.state,
|
|
177
|
+
enumerable: false,
|
|
178
|
+
configurable: true
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
if ('$' in tree) {
|
|
182
|
+
Object.defineProperty(enhancedTree, '$', {
|
|
183
|
+
value: tree.$,
|
|
184
|
+
enumerable: false,
|
|
185
|
+
configurable: true
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
Object.assign(enhancedTree, batchingMethods);
|
|
189
|
+
enhancedTree.batchUpdate = updater => {
|
|
190
|
+
enhancedTree.batch(() => {
|
|
191
|
+
const current = originalTreeCall();
|
|
192
|
+
const updates = updater(current);
|
|
193
|
+
Object.entries(updates).forEach(([key, value]) => {
|
|
194
|
+
const property = enhancedTree.state[key];
|
|
195
|
+
if (property && typeof property.set === 'function') {
|
|
196
|
+
property.set(value);
|
|
197
|
+
} else if (typeof property === 'function') {
|
|
198
|
+
property(value);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
};
|
|
203
|
+
return enhancedTree;
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
function highPerformanceBatching() {
|
|
207
|
+
return batching({
|
|
208
|
+
enabled: true,
|
|
209
|
+
notificationDelayMs: 0
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
function batchingWithConfig(config = {}) {
|
|
213
|
+
return batching(config);
|
|
214
|
+
}
|
|
215
|
+
function flushBatchedUpdates() {
|
|
216
|
+
console.warn('[SignalTree] flushBatchedUpdates() is deprecated. Use tree.flushNotifications() instead.');
|
|
217
|
+
}
|
|
218
|
+
function hasPendingUpdates() {
|
|
219
|
+
console.warn('[SignalTree] hasPendingUpdates() is deprecated. Use tree.hasPendingNotifications() instead.');
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
function getBatchQueueSize() {
|
|
223
|
+
console.warn('[SignalTree] getBatchQueueSize() is deprecated. Signal writes are now synchronous.');
|
|
224
|
+
return 0;
|
|
225
|
+
}
|
|
226
|
+
Object.assign((config = {}) => batching(config), {
|
|
227
|
+
highPerformance: highPerformanceBatching
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
export { batching, batchingWithConfig, flushBatchedUpdates, getBatchQueueSize, hasPendingUpdates, highPerformanceBatching };
|