@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.
Files changed (102) hide show
  1. package/README.md +392 -0
  2. package/dist/constants.js +6 -0
  3. package/dist/deep-equal.js +41 -0
  4. package/dist/enhancers/batching/batching.js +230 -0
  5. package/dist/enhancers/devtools/devtools.js +318 -0
  6. package/dist/enhancers/effects/effects.js +66 -0
  7. package/dist/enhancers/entities/entities.js +7 -0
  8. package/dist/enhancers/index.js +72 -0
  9. package/dist/enhancers/memoization/memoization.js +420 -0
  10. package/dist/enhancers/presets/lib/presets.js +27 -0
  11. package/dist/enhancers/serialization/constants.js +15 -0
  12. package/dist/enhancers/serialization/serialization.js +656 -0
  13. package/dist/enhancers/time-travel/time-travel.js +283 -0
  14. package/dist/enhancers/time-travel/utils.js +11 -0
  15. package/dist/enhancers/utils/copy-tree-properties.js +20 -0
  16. package/dist/index.js +27 -0
  17. package/dist/is-built-in-object.js +23 -0
  18. package/dist/lib/async-helpers.js +77 -0
  19. package/dist/lib/constants.js +56 -0
  20. package/dist/lib/edit-session.js +84 -0
  21. package/dist/lib/entity-signal.js +544 -0
  22. package/dist/lib/internals/batch-scope.js +8 -0
  23. package/dist/lib/internals/derived-types.js +6 -0
  24. package/dist/lib/internals/materialize-markers.js +72 -0
  25. package/dist/lib/internals/merge-derived.js +59 -0
  26. package/dist/lib/markers/derived.js +6 -0
  27. package/dist/lib/markers/entity-map.js +41 -0
  28. package/dist/lib/markers/form.js +310 -0
  29. package/dist/lib/markers/status.js +71 -0
  30. package/dist/lib/markers/stored.js +213 -0
  31. package/dist/lib/memory/memory-manager.js +164 -0
  32. package/dist/lib/path-notifier.js +178 -0
  33. package/dist/lib/presets.js +20 -0
  34. package/dist/lib/security/security-validator.js +121 -0
  35. package/dist/lib/signal-tree.js +416 -0
  36. package/dist/lib/types.js +3 -0
  37. package/dist/lib/utils.js +264 -0
  38. package/dist/lru-cache.js +64 -0
  39. package/dist/parse-path.js +13 -0
  40. package/package.json +1 -1
  41. package/src/enhancers/batching/batching.d.ts +10 -0
  42. package/src/enhancers/batching/batching.types.d.ts +1 -0
  43. package/src/enhancers/batching/index.d.ts +1 -0
  44. package/src/enhancers/batching/test-setup.d.ts +3 -0
  45. package/src/enhancers/devtools/devtools.d.ts +68 -0
  46. package/src/enhancers/devtools/devtools.types.d.ts +1 -0
  47. package/src/enhancers/devtools/index.d.ts +1 -0
  48. package/src/enhancers/devtools/test-setup.d.ts +3 -0
  49. package/src/enhancers/effects/effects.d.ts +9 -0
  50. package/src/enhancers/effects/effects.types.d.ts +1 -0
  51. package/src/enhancers/effects/index.d.ts +1 -0
  52. package/src/enhancers/entities/entities.d.ts +7 -0
  53. package/src/enhancers/entities/entities.types.d.ts +1 -0
  54. package/src/enhancers/entities/index.d.ts +1 -0
  55. package/src/enhancers/entities/test-setup.d.ts +3 -0
  56. package/src/enhancers/index.d.ts +3 -0
  57. package/src/enhancers/memoization/index.d.ts +1 -0
  58. package/src/enhancers/memoization/memoization.d.ts +54 -0
  59. package/src/enhancers/memoization/memoization.types.d.ts +1 -0
  60. package/src/enhancers/memoization/test-setup.d.ts +3 -0
  61. package/src/enhancers/presets/index.d.ts +1 -0
  62. package/src/enhancers/presets/lib/presets.d.ts +8 -0
  63. package/src/enhancers/serialization/constants.d.ts +14 -0
  64. package/src/enhancers/serialization/index.d.ts +2 -0
  65. package/src/enhancers/serialization/serialization.d.ts +68 -0
  66. package/src/enhancers/serialization/test-setup.d.ts +3 -0
  67. package/src/enhancers/test-helpers/types-equals.d.ts +2 -0
  68. package/src/enhancers/time-travel/index.d.ts +1 -0
  69. package/src/enhancers/time-travel/test-setup.d.ts +3 -0
  70. package/src/enhancers/time-travel/time-travel.d.ts +10 -0
  71. package/src/enhancers/time-travel/time-travel.types.d.ts +1 -0
  72. package/src/enhancers/time-travel/utils.d.ts +2 -0
  73. package/src/enhancers/types.d.ts +1 -0
  74. package/src/enhancers/typing/helpers-types.d.ts +2 -0
  75. package/src/enhancers/utils/copy-tree-properties.d.ts +1 -0
  76. package/src/index.d.ts +27 -0
  77. package/src/lib/async-helpers.d.ts +8 -0
  78. package/src/lib/constants.d.ts +41 -0
  79. package/src/lib/dev-proxy.d.ts +3 -0
  80. package/src/lib/edit-session.d.ts +21 -0
  81. package/src/lib/entity-signal.d.ts +1 -0
  82. package/src/lib/internals/batch-scope.d.ts +3 -0
  83. package/src/lib/internals/builder-types.d.ts +13 -0
  84. package/src/lib/internals/derived-types.d.ts +19 -0
  85. package/src/lib/internals/materialize-markers.d.ts +5 -0
  86. package/src/lib/internals/merge-derived.d.ts +4 -0
  87. package/src/lib/markers/derived.d.ts +9 -0
  88. package/src/lib/markers/entity-map.d.ts +19 -0
  89. package/src/lib/markers/form.d.ts +86 -0
  90. package/src/lib/markers/index.d.ts +4 -0
  91. package/src/lib/markers/status.d.ts +32 -0
  92. package/src/lib/markers/stored.d.ts +35 -0
  93. package/src/lib/memory/memory-manager.d.ts +30 -0
  94. package/src/lib/path-notifier.d.ts +34 -0
  95. package/src/lib/performance/diff-engine.d.ts +33 -0
  96. package/src/lib/performance/path-index.d.ts +25 -0
  97. package/src/lib/performance/update-engine.d.ts +32 -0
  98. package/src/lib/presets.d.ts +34 -0
  99. package/src/lib/security/security-validator.d.ts +33 -0
  100. package/src/lib/signal-tree.d.ts +6 -0
  101. package/src/lib/types.d.ts +301 -0
  102. 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,6 @@
1
+ const SHARED_DEFAULTS = Object.freeze({
2
+ PATH_CACHE_SIZE: 1000
3
+ });
4
+ const DEFAULT_PATH_CACHE_SIZE = SHARED_DEFAULTS.PATH_CACHE_SIZE;
5
+
6
+ export { DEFAULT_PATH_CACHE_SIZE, SHARED_DEFAULTS };
@@ -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 };