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 CHANGED
@@ -2,11 +2,10 @@
2
2
 
3
3
  [![Angular 16-21](https://img.shields.io/badge/Angular-16--21-dd0031)](https://angular.dev/)
4
4
  [![npm version](https://img.shields.io/npm/v/ngx-signal-plus.svg)](https://www.npmjs.com/package/ngx-signal-plus)
5
- ![Coverage](https://img.shields.io/badge/coverage-89.14%25-brightgreen)
5
+ ![Coverage](https://img.shields.io/badge/coverage-90.44%25-brightgreen)
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
- // Helper to conditionally clone only complex objects
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
- * Helper function to enforce history size limit
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 validators = this.options.validators;
779
- if (validators.length > 0) {
780
- // Check validators in sequence and stop on first failure
781
- for (const validator of validators) {
782
- try {
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
- try {
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
- try {
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 `SignalPlus`-like object
3974
- * that allows features like persistence, history tracking, and validation for derived values.
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 processValue = (value) => {
4070
- const transformedValue = applyTransform(value);
4071
- previousValue = internalSignal();
4072
- internalSignal.set(transformedValue);
4073
- historySignal.update((h) => [...h, transformedValue].slice(-historySize));
4074
- redoStackSignal.set([]);
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
- if (!validate)
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
- if (!validate)
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([]);