ngx-signal-plus 2.4.2 → 2.5.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 +7 -2
- package/fesm2022/ngx-signal-plus.mjs +113 -161
- package/fesm2022/ngx-signal-plus.mjs.map +1 -1
- package/index.d.ts +60 -51
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,11 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://angular.dev/)
|
|
4
4
|
[](https://www.npmjs.com/package/ngx-signal-plus)
|
|
5
|
-

|
|
6
6
|
|
|
7
7
|
Bring validation, persistence, undo/redo, and reactive queries to Angular Signals on Angular 16+.
|
|
8
8
|
|
|
9
|
-
- Interactive playground: https://stackblitz.com/github/milad-hub/ngx-signal-plus
|
|
10
9
|
- Full API docs: https://github.com/milad-hub/ngx-signal-plus/blob/main/projects/signal-plus/docs/API.md
|
|
11
10
|
- Repository README (contributors): https://github.com/milad-hub/ngx-signal-plus/blob/main/README.md
|
|
12
11
|
|
|
@@ -78,6 +77,11 @@ export class CounterComponent {
|
|
|
78
77
|
- Schema validation: `spSchema`, `spSchemaValidator`
|
|
79
78
|
- Middleware: `spUseMiddleware`, `spRemoveMiddleware`, `spLoggerMiddleware`, `spAnalyticsMiddleware`
|
|
80
79
|
|
|
80
|
+
## Foundations Updates
|
|
81
|
+
|
|
82
|
+
- `spComputed()` now exposes a read-only surface via `ReadonlySignalPlus<T>`.
|
|
83
|
+
- `SignalPlus<T>` now includes `errors: Signal<string[]>` for consistent validation error access.
|
|
84
|
+
|
|
81
85
|
## Comparisons
|
|
82
86
|
|
|
83
87
|
### ngx-signal-plus vs Angular native signals
|
|
@@ -103,6 +107,7 @@ export class CounterComponent {
|
|
|
103
107
|
- Akita is a store-centric architecture built around RxJS stores/queries.
|
|
104
108
|
- ngx-signal-plus is signal-first and utility-first, designed for composable local/global signal state without store boilerplate.
|
|
105
109
|
- Akita is no longer actively evolving like modern signal-first tools: the npm package is old (8.0.1, last published years ago), and the GitHub repository is archived.
|
|
110
|
+
|
|
106
111
|
## Documentation
|
|
107
112
|
|
|
108
113
|
- API documentation: https://github.com/milad-hub/ngx-signal-plus/blob/main/projects/signal-plus/docs/API.md
|
|
@@ -461,18 +461,7 @@ class SignalBuilder {
|
|
|
461
461
|
build() {
|
|
462
462
|
// Get transform function
|
|
463
463
|
const transform = this.options.transform || ((value) => value);
|
|
464
|
-
|
|
465
|
-
const conditionalClone = (value) => {
|
|
466
|
-
const type = typeof value;
|
|
467
|
-
if (type === 'string' ||
|
|
468
|
-
type === 'number' ||
|
|
469
|
-
type === 'boolean' ||
|
|
470
|
-
value === null ||
|
|
471
|
-
value === undefined) {
|
|
472
|
-
return value;
|
|
473
|
-
}
|
|
474
|
-
return structuredClone(value);
|
|
475
|
-
};
|
|
464
|
+
const conditionalClone = (value) => this.cloneIfNeeded(value);
|
|
476
465
|
// Create signal with initial value (untransformed)
|
|
477
466
|
const writable = signal(structuredClone(this.options.initialValue), ...(ngDevMode ? [{ debugName: "writable" }] : []));
|
|
478
467
|
let previousValue = structuredClone(this.options.initialValue);
|
|
@@ -489,31 +478,8 @@ class SignalBuilder {
|
|
|
489
478
|
let currentValidationAbortController = null;
|
|
490
479
|
const isValidatingSignal = signal(false, ...(ngDevMode ? [{ debugName: "isValidatingSignal" }] : []));
|
|
491
480
|
const asyncErrorsSignal = signal([], ...(ngDevMode ? [{ debugName: "asyncErrorsSignal" }] : []));
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
* @param histArray The history array to enforce size on
|
|
495
|
-
* @returns The history array with size limit applied
|
|
496
|
-
*/
|
|
497
|
-
const enforceHistorySize = (histArray) => {
|
|
498
|
-
if (this.options.historySize &&
|
|
499
|
-
histArray.length > this.options.historySize) {
|
|
500
|
-
return histArray.slice(-this.options.historySize);
|
|
501
|
-
}
|
|
502
|
-
return histArray;
|
|
503
|
-
};
|
|
504
|
-
/**
|
|
505
|
-
* Helper function to enforce redo stack size limit
|
|
506
|
-
* @param redoArray The redo stack array to enforce size on
|
|
507
|
-
* @returns The redo stack array with size limit applied
|
|
508
|
-
*/
|
|
509
|
-
const enforceRedoStackSize = (redoArray) => {
|
|
510
|
-
// Use the same size limit as history for consistency
|
|
511
|
-
if (this.options.historySize &&
|
|
512
|
-
redoArray.length > this.options.historySize) {
|
|
513
|
-
return redoArray.slice(-this.options.historySize);
|
|
514
|
-
}
|
|
515
|
-
return redoArray;
|
|
516
|
-
};
|
|
481
|
+
const enforceHistorySize = (histArray) => this.limitByHistorySize(histArray, this.options.historySize);
|
|
482
|
+
const enforceRedoStackSize = (redoArray) => this.limitByHistorySize(redoArray, this.options.historySize);
|
|
517
483
|
/**
|
|
518
484
|
* Helper function to run async validation with debouncing and cancellation
|
|
519
485
|
* @param value The value to validate
|
|
@@ -592,42 +558,7 @@ class SignalBuilder {
|
|
|
592
558
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
593
559
|
data,
|
|
594
560
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
595
|
-
fallbackData) =>
|
|
596
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
597
|
-
const safeStringify = (obj) => {
|
|
598
|
-
const seen = new WeakSet();
|
|
599
|
-
return JSON.stringify(obj, (key, value) => {
|
|
600
|
-
if (value !== null && typeof value === 'object') {
|
|
601
|
-
if (seen.has(value)) {
|
|
602
|
-
return '[Circular Reference]';
|
|
603
|
-
}
|
|
604
|
-
seen.add(value);
|
|
605
|
-
}
|
|
606
|
-
return value;
|
|
607
|
-
});
|
|
608
|
-
};
|
|
609
|
-
try {
|
|
610
|
-
return JSON.stringify(data);
|
|
611
|
-
}
|
|
612
|
-
catch (error) {
|
|
613
|
-
if (error instanceof TypeError && error.message.includes('circular')) {
|
|
614
|
-
return safeStringify(data);
|
|
615
|
-
}
|
|
616
|
-
if (fallbackData !== undefined) {
|
|
617
|
-
try {
|
|
618
|
-
return JSON.stringify(fallbackData);
|
|
619
|
-
}
|
|
620
|
-
catch (fallbackError) {
|
|
621
|
-
if (fallbackError instanceof TypeError &&
|
|
622
|
-
fallbackError.message.includes('circular')) {
|
|
623
|
-
return safeStringify(fallbackData);
|
|
624
|
-
}
|
|
625
|
-
throw fallbackError;
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
|
-
throw error;
|
|
629
|
-
}
|
|
630
|
-
};
|
|
561
|
+
fallbackData) => this.serializeWithCircularCheck(data, fallbackData);
|
|
631
562
|
// Initialize history with initial value
|
|
632
563
|
if (this.options.enableHistory) {
|
|
633
564
|
history.set([conditionalClone(initialValue)]);
|
|
@@ -730,6 +661,7 @@ class SignalBuilder {
|
|
|
730
661
|
this.handleError(error);
|
|
731
662
|
});
|
|
732
663
|
};
|
|
664
|
+
const getValidationErrors = (value) => this.collectValidationErrors(this.options.validators, value);
|
|
733
665
|
const subscribe = (callback) => {
|
|
734
666
|
const subId = nextSubId++;
|
|
735
667
|
subscribers.set(subId, callback);
|
|
@@ -775,23 +707,11 @@ class SignalBuilder {
|
|
|
775
707
|
}
|
|
776
708
|
// Then validate the transformed value
|
|
777
709
|
if (!skipValidation) {
|
|
778
|
-
const
|
|
779
|
-
if (
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
const result = validator(transformedValue);
|
|
784
|
-
if (!result) {
|
|
785
|
-
const error = new Error('Validation failed');
|
|
786
|
-
this.handleError(error);
|
|
787
|
-
throw error;
|
|
788
|
-
}
|
|
789
|
-
}
|
|
790
|
-
catch (error) {
|
|
791
|
-
this.handleError(error);
|
|
792
|
-
throw error;
|
|
793
|
-
}
|
|
794
|
-
}
|
|
710
|
+
const validationErrors = getValidationErrors(transformedValue);
|
|
711
|
+
if (validationErrors.length > 0) {
|
|
712
|
+
const error = new Error(validationErrors[0]);
|
|
713
|
+
this.handleError(error);
|
|
714
|
+
throw error;
|
|
795
715
|
}
|
|
796
716
|
}
|
|
797
717
|
// Check if value is distinct
|
|
@@ -990,42 +910,11 @@ class SignalBuilder {
|
|
|
990
910
|
}
|
|
991
911
|
},
|
|
992
912
|
validate: () => {
|
|
993
|
-
|
|
994
|
-
const validators = this.options
|
|
995
|
-
.validators;
|
|
996
|
-
return validators.every((validator) => {
|
|
997
|
-
try {
|
|
998
|
-
return validator(writable());
|
|
999
|
-
}
|
|
1000
|
-
catch (error) {
|
|
1001
|
-
this.handleError(error);
|
|
1002
|
-
return false;
|
|
1003
|
-
}
|
|
1004
|
-
});
|
|
1005
|
-
}
|
|
1006
|
-
catch (error) {
|
|
1007
|
-
this.handleError(error);
|
|
1008
|
-
return false;
|
|
1009
|
-
}
|
|
913
|
+
return getValidationErrors(writable()).length === 0;
|
|
1010
914
|
},
|
|
915
|
+
errors: computed(() => getValidationErrors(writable())),
|
|
1011
916
|
isValid: computed(() => {
|
|
1012
|
-
|
|
1013
|
-
const validators = this.options
|
|
1014
|
-
.validators;
|
|
1015
|
-
return validators.every((validator) => {
|
|
1016
|
-
try {
|
|
1017
|
-
return validator(writable());
|
|
1018
|
-
}
|
|
1019
|
-
catch (error) {
|
|
1020
|
-
this.handleError(error);
|
|
1021
|
-
return false;
|
|
1022
|
-
}
|
|
1023
|
-
});
|
|
1024
|
-
}
|
|
1025
|
-
catch (error) {
|
|
1026
|
-
this.handleError(error);
|
|
1027
|
-
return false;
|
|
1028
|
-
}
|
|
917
|
+
return getValidationErrors(writable()).length === 0;
|
|
1029
918
|
}),
|
|
1030
919
|
isValidating: computed(() => isValidatingSignal()),
|
|
1031
920
|
asyncErrors: computed(() => asyncErrorsSignal()),
|
|
@@ -1310,6 +1199,92 @@ class SignalBuilder {
|
|
|
1310
1199
|
};
|
|
1311
1200
|
return signalInstance;
|
|
1312
1201
|
}
|
|
1202
|
+
cloneIfNeeded(value) {
|
|
1203
|
+
const type = typeof value;
|
|
1204
|
+
if (type === 'string' ||
|
|
1205
|
+
type === 'number' ||
|
|
1206
|
+
type === 'boolean' ||
|
|
1207
|
+
value === null ||
|
|
1208
|
+
value === undefined) {
|
|
1209
|
+
return value;
|
|
1210
|
+
}
|
|
1211
|
+
return structuredClone(value);
|
|
1212
|
+
}
|
|
1213
|
+
limitByHistorySize(values, historySize) {
|
|
1214
|
+
if (historySize && values.length > historySize) {
|
|
1215
|
+
return values.slice(-historySize);
|
|
1216
|
+
}
|
|
1217
|
+
return values;
|
|
1218
|
+
}
|
|
1219
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1220
|
+
serializeWithCircularCheck(data, fallbackData) {
|
|
1221
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1222
|
+
const safeStringify = (obj) => {
|
|
1223
|
+
const seen = new WeakSet();
|
|
1224
|
+
return JSON.stringify(obj, (key, value) => {
|
|
1225
|
+
if (value !== null && typeof value === 'object') {
|
|
1226
|
+
if (seen.has(value)) {
|
|
1227
|
+
return '[Circular Reference]';
|
|
1228
|
+
}
|
|
1229
|
+
seen.add(value);
|
|
1230
|
+
}
|
|
1231
|
+
return value;
|
|
1232
|
+
});
|
|
1233
|
+
};
|
|
1234
|
+
try {
|
|
1235
|
+
return JSON.stringify(data);
|
|
1236
|
+
}
|
|
1237
|
+
catch (error) {
|
|
1238
|
+
if (error instanceof TypeError && error.message.includes('circular')) {
|
|
1239
|
+
return safeStringify(data);
|
|
1240
|
+
}
|
|
1241
|
+
if (fallbackData !== undefined) {
|
|
1242
|
+
try {
|
|
1243
|
+
return JSON.stringify(fallbackData);
|
|
1244
|
+
}
|
|
1245
|
+
catch (fallbackError) {
|
|
1246
|
+
if (fallbackError instanceof TypeError &&
|
|
1247
|
+
fallbackError.message.includes('circular')) {
|
|
1248
|
+
return safeStringify(fallbackData);
|
|
1249
|
+
}
|
|
1250
|
+
throw fallbackError;
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
throw error;
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
collectValidationErrors(validators, value) {
|
|
1257
|
+
try {
|
|
1258
|
+
if (!validators || validators.length === 0) {
|
|
1259
|
+
return [];
|
|
1260
|
+
}
|
|
1261
|
+
const errors = [];
|
|
1262
|
+
for (const validator of validators) {
|
|
1263
|
+
try {
|
|
1264
|
+
const result = validator(value);
|
|
1265
|
+
if (result === true) {
|
|
1266
|
+
continue;
|
|
1267
|
+
}
|
|
1268
|
+
if (typeof result === 'string') {
|
|
1269
|
+
errors.push(result);
|
|
1270
|
+
continue;
|
|
1271
|
+
}
|
|
1272
|
+
errors.push('Validation failed');
|
|
1273
|
+
break;
|
|
1274
|
+
}
|
|
1275
|
+
catch (error) {
|
|
1276
|
+
this.handleError(error);
|
|
1277
|
+
errors.push(error instanceof Error ? error.message : 'Validation failed');
|
|
1278
|
+
break;
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
return errors;
|
|
1282
|
+
}
|
|
1283
|
+
catch (error) {
|
|
1284
|
+
this.handleError(error);
|
|
1285
|
+
return ['Validation failed'];
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1313
1288
|
/**
|
|
1314
1289
|
* Internal method to handle errors through registered error handlers.
|
|
1315
1290
|
* @param error The error to handle
|
|
@@ -3970,8 +3945,8 @@ function spCollection(options) {
|
|
|
3970
3945
|
/**
|
|
3971
3946
|
* Creates an enhanced computed signal that supports persistence, history, and validation.
|
|
3972
3947
|
*
|
|
3973
|
-
* Unlike Angular's built-in `computed()`, `spComputed` returns a
|
|
3974
|
-
* that allows
|
|
3948
|
+
* Unlike Angular's built-in `computed()`, `spComputed` returns a read-only SignalPlus shape
|
|
3949
|
+
* that allows persistence, history tracking, and validation for derived values.
|
|
3975
3950
|
*
|
|
3976
3951
|
* @template T - The type of the computed value
|
|
3977
3952
|
* @param fn - A function that computes the value from source signals
|
|
@@ -4001,7 +3976,6 @@ function spComputed(fn, options = {}) {
|
|
|
4001
3976
|
const internalSignal = signal(initialValue, ...(ngDevMode ? [{ debugName: "internalSignal" }] : []));
|
|
4002
3977
|
const historySignal = signal([initialValue], ...(ngDevMode ? [{ debugName: "historySignal" }] : []));
|
|
4003
3978
|
const redoStackSignal = signal([], ...(ngDevMode ? [{ debugName: "redoStackSignal" }] : []));
|
|
4004
|
-
const errorsSignal = signal([], ...(ngDevMode ? [{ debugName: "errorsSignal" }] : []));
|
|
4005
3979
|
let previousValue = initialValue;
|
|
4006
3980
|
const storedInitialValue = initialValue;
|
|
4007
3981
|
if (persist && isBrowser()) {
|
|
@@ -4028,10 +4002,6 @@ function spComputed(fn, options = {}) {
|
|
|
4028
4002
|
const newHistory = [...currentHistory, newValue].slice(-historySize);
|
|
4029
4003
|
historySignal.set(newHistory);
|
|
4030
4004
|
redoStackSignal.set([]);
|
|
4031
|
-
if (validate) {
|
|
4032
|
-
const isValid = validate(newValue);
|
|
4033
|
-
errorsSignal.set(isValid ? [] : ['Validation failed']);
|
|
4034
|
-
}
|
|
4035
4005
|
if (persist && isBrowser()) {
|
|
4036
4006
|
safeLocalStorageSet(persist, JSON.stringify(newValue));
|
|
4037
4007
|
}
|
|
@@ -4066,17 +4036,18 @@ function spComputed(fn, options = {}) {
|
|
|
4066
4036
|
safeLocalStorageSet(persist, JSON.stringify(valueToRedo));
|
|
4067
4037
|
}
|
|
4068
4038
|
};
|
|
4069
|
-
const
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
|
|
4039
|
+
const getValidationErrors = (value) => {
|
|
4040
|
+
if (!validate) {
|
|
4041
|
+
return [];
|
|
4042
|
+
}
|
|
4043
|
+
const result = validate(value);
|
|
4044
|
+
if (result === true) {
|
|
4045
|
+
return [];
|
|
4046
|
+
}
|
|
4047
|
+
return typeof result === 'string' ? [result] : ['Validation failed'];
|
|
4075
4048
|
};
|
|
4076
4049
|
const isValidSignal = computed(() => {
|
|
4077
|
-
|
|
4078
|
-
return true;
|
|
4079
|
-
return validate(internalSignal());
|
|
4050
|
+
return getValidationErrors(internalSignal()).length === 0;
|
|
4080
4051
|
}, ...(ngDevMode ? [{ debugName: "isValidSignal" }] : []));
|
|
4081
4052
|
const isDirtySignal = computed(() => {
|
|
4082
4053
|
return (JSON.stringify(internalSignal()) !== JSON.stringify(storedInitialValue));
|
|
@@ -4096,27 +4067,11 @@ function spComputed(fn, options = {}) {
|
|
|
4096
4067
|
},
|
|
4097
4068
|
signal: computed(() => internalSignal()),
|
|
4098
4069
|
writable: internalSignal,
|
|
4099
|
-
set: processValue,
|
|
4100
|
-
setValue: processValue,
|
|
4101
|
-
update: (updateFn) => {
|
|
4102
|
-
const newValue = applyTransform(updateFn(internalSignal()));
|
|
4103
|
-
previousValue = internalSignal();
|
|
4104
|
-
internalSignal.set(newValue);
|
|
4105
|
-
historySignal.update((h) => [...h, newValue].slice(-historySize));
|
|
4106
|
-
redoStackSignal.set([]);
|
|
4107
|
-
},
|
|
4108
|
-
reset: () => {
|
|
4109
|
-
previousValue = internalSignal();
|
|
4110
|
-
internalSignal.set(storedInitialValue);
|
|
4111
|
-
historySignal.set([storedInitialValue]);
|
|
4112
|
-
redoStackSignal.set([]);
|
|
4113
|
-
},
|
|
4114
4070
|
validate: () => {
|
|
4115
|
-
|
|
4116
|
-
return true;
|
|
4117
|
-
return validate(internalSignal());
|
|
4071
|
+
return getValidationErrors(internalSignal()).length === 0;
|
|
4118
4072
|
},
|
|
4119
4073
|
isValid: isValidSignal,
|
|
4074
|
+
errors: computed(() => getValidationErrors(internalSignal())),
|
|
4120
4075
|
isValidating: computed(() => false),
|
|
4121
4076
|
asyncErrors: computed(() => []),
|
|
4122
4077
|
isDirty: isDirtySignal,
|
|
@@ -4130,9 +4085,6 @@ function spComputed(fn, options = {}) {
|
|
|
4130
4085
|
}, ...(ngDevMode ? [{ debugName: "effectRef" }] : []));
|
|
4131
4086
|
return () => effectRef.destroy();
|
|
4132
4087
|
},
|
|
4133
|
-
pipe: () => {
|
|
4134
|
-
throw new Error('pipe is not supported for spComputed');
|
|
4135
|
-
},
|
|
4136
4088
|
destroy: () => {
|
|
4137
4089
|
historySignal.set([]);
|
|
4138
4090
|
redoStackSignal.set([]);
|