ngx-signal-plus 2.7.0 → 2.8.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,7 +2,7 @@
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-90.92%25-brightgreen)
5
+ ![Coverage](https://img.shields.io/badge/coverage-91.04%25-brightgreen)
6
6
 
7
7
  Bring validation, persistence, undo/redo, and reactive queries to Angular Signals on Angular 16+.
8
8
 
@@ -70,7 +70,7 @@ export class CounterComponent {
70
70
  - Signal creation: `sp`, `spCounter`, `spToggle`, `spForm`, `spComputed`
71
71
  - Signal enhancement: `enhance`
72
72
  - Operators: `spMap`, `spFilter`, `spDebounceTime`, `spThrottleTime`, `spDelay`, `spDistinctUntilChanged`
73
- - Developer experience: `spCombine`, `spAll`, `spAny`, `spEffect`, `spDebug`, `spMonitor`, `sp().debug(label)`
73
+ - Developer experience: `spCombine`, `spAll`, `spAny`, `spEffect`, `spDebug`, `spMonitor`, `sp().debug(label)`, `sp().monitor(options)`
74
74
  - Forms and groups: `spForm`, `spFormGroup`
75
75
  - Async helpers: `spAsync`, `spCollection`
76
76
  - Reactive queries: `spQuery`, `createDependentQuery`, `spInfiniteQuery`, `spMutation`, `QueryClient`, `setGlobalQueryClient`, `getGlobalQueryClient`
@@ -82,6 +82,8 @@ export class CounterComponent {
82
82
 
83
83
  - `spComputed()` now exposes a read-only surface via `ReadonlySignalPlus<T>`.
84
84
  - `SignalPlus<T>` now includes `errors: Signal<string[]>` for consistent validation error access.
85
+ - Builder monitoring is available via `sp().monitor(options)` and records updates through `spMonitor`.
86
+ - Registered middleware now runs in normal `set`/`setValue`/`update` runtime paths for built signals.
85
87
 
86
88
  ## Comparisons
87
89
 
@@ -121,3 +123,5 @@ MIT
121
123
 
122
124
 
123
125
 
126
+
127
+
@@ -323,6 +323,138 @@ const spDebug = {
323
323
  },
324
324
  };
325
325
 
326
+ const metrics = new Map();
327
+ let globalEnabled = true;
328
+ const ensureMetric = (name) => {
329
+ const existing = metrics.get(name);
330
+ if (existing) {
331
+ return existing;
332
+ }
333
+ const created = {
334
+ name,
335
+ updates: 0,
336
+ enabled: true,
337
+ totalDurationMs: 0,
338
+ averageDurationMs: 0,
339
+ maxDurationMs: 0,
340
+ lastDurationMs: 0,
341
+ lastUpdated: null,
342
+ };
343
+ metrics.set(name, created);
344
+ return created;
345
+ };
346
+ const spMonitor = {
347
+ trackSignal(name) {
348
+ ensureMetric(name);
349
+ },
350
+ recordUpdate(name, durationMs = 0) {
351
+ const metric = ensureMetric(name);
352
+ if (!globalEnabled || !metric.enabled) {
353
+ return;
354
+ }
355
+ metric.updates += 1;
356
+ metric.lastDurationMs = durationMs;
357
+ metric.totalDurationMs += durationMs;
358
+ metric.averageDurationMs = metric.totalDurationMs / metric.updates;
359
+ metric.maxDurationMs = Math.max(metric.maxDurationMs, durationMs);
360
+ metric.lastUpdated = Date.now();
361
+ },
362
+ enable(name) {
363
+ ensureMetric(name).enabled = true;
364
+ },
365
+ disable(name) {
366
+ ensureMetric(name).enabled = false;
367
+ },
368
+ enableAll() {
369
+ globalEnabled = true;
370
+ },
371
+ disableAll() {
372
+ globalEnabled = false;
373
+ },
374
+ getHotSignals(limit = 10) {
375
+ return Array.from(metrics.values())
376
+ .filter((metric) => metric.enabled)
377
+ .sort((a, b) => b.updates - a.updates)
378
+ .slice(0, limit)
379
+ .map((metric) => ({ ...metric }));
380
+ },
381
+ getSlowSignals(thresholdMs = 16) {
382
+ return Array.from(metrics.values())
383
+ .filter((metric) => metric.enabled && metric.averageDurationMs >= thresholdMs)
384
+ .sort((a, b) => b.averageDurationMs - a.averageDurationMs)
385
+ .map((metric) => ({ ...metric }));
386
+ },
387
+ exportMetrics(format = 'object') {
388
+ const exported = Array.from(metrics.values()).map((metric) => ({ ...metric }));
389
+ return format === 'json' ? JSON.stringify(exported) : exported;
390
+ },
391
+ clear() {
392
+ metrics.clear();
393
+ globalEnabled = true;
394
+ },
395
+ };
396
+
397
+ /**
398
+ * Middleware system for intercepting signal operations.
399
+ */
400
+ const middlewareRegistry = [];
401
+ function spUseMiddleware(middleware) {
402
+ if (!middlewareRegistry.some((m) => m.name === middleware.name)) {
403
+ middlewareRegistry.push(middleware);
404
+ }
405
+ }
406
+ function spRemoveMiddleware(name) {
407
+ const index = middlewareRegistry.findIndex((m) => m.name === name);
408
+ if (index === -1)
409
+ return false;
410
+ middlewareRegistry.splice(index, 1);
411
+ return true;
412
+ }
413
+ function spClearMiddleware() {
414
+ middlewareRegistry.length = 0;
415
+ }
416
+ function spGetMiddlewareCount() {
417
+ return middlewareRegistry.length;
418
+ }
419
+ function spRunMiddleware(context) {
420
+ for (const m of middlewareRegistry) {
421
+ try {
422
+ m.onSet?.(context);
423
+ }
424
+ catch {
425
+ /* ignore */
426
+ }
427
+ }
428
+ }
429
+ function spRunMiddlewareError(error, context) {
430
+ for (const m of middlewareRegistry) {
431
+ try {
432
+ m.onError?.(error, context);
433
+ }
434
+ catch {
435
+ /* ignore */
436
+ }
437
+ }
438
+ }
439
+ function spLoggerMiddleware(prefix = '[Signal]') {
440
+ return {
441
+ name: 'sp-logger',
442
+ onSet: (ctx) => console.log(`${prefix} ${ctx.signalName || 'signal'}: ${JSON.stringify(ctx.oldValue)} -> ${JSON.stringify(ctx.newValue)}`),
443
+ onError: (error, ctx) => console.error(`${prefix} Error in ${ctx.signalName || 'signal'}:`, error.message),
444
+ };
445
+ }
446
+ function spAnalyticsMiddleware(tracker) {
447
+ return {
448
+ name: 'sp-analytics',
449
+ onSet: (ctx) => tracker({
450
+ name: ctx.signalName || 'unknown',
451
+ oldValue: ctx.oldValue,
452
+ newValue: ctx.newValue,
453
+ timestamp: ctx.timestamp,
454
+ }),
455
+ };
456
+ }
457
+
326
458
  /**
327
459
  * @fileoverview Builder class for creating enhanced Angular signals
328
460
  * Provides a fluent API for configuring signals with features like:
@@ -345,6 +477,7 @@ const spDebug = {
345
477
  */
346
478
  class SignalBuilder {
347
479
  options;
480
+ static monitorCounter = 0;
348
481
  /**
349
482
  * Creates a new SignalBuilder instance
350
483
  * @param initialValue Initial value for the signal
@@ -448,6 +581,10 @@ class SignalBuilder {
448
581
  this.options.debugLabel = label;
449
582
  return this;
450
583
  }
584
+ monitor(options = {}) {
585
+ this.options.monitorOptions = { ...options };
586
+ return this;
587
+ }
451
588
  /**
452
589
  * Transforms signal value to a different type
453
590
  * @param fn Transform function from T to R
@@ -479,6 +616,9 @@ class SignalBuilder {
479
616
  newBuilder.options.storageKey = this.options.storageKey;
480
617
  newBuilder.options.debounceTime = this.options.debounceTime;
481
618
  newBuilder.options.autoCleanup = this.options.autoCleanup;
619
+ newBuilder.options.monitorOptions = this.options.monitorOptions
620
+ ? { ...this.options.monitorOptions }
621
+ : undefined;
482
622
  return newBuilder;
483
623
  }
484
624
  /**
@@ -549,6 +689,20 @@ class SignalBuilder {
549
689
  const asyncErrorsSignal = signal([], ...(ngDevMode ? [{ debugName: "asyncErrorsSignal" }] : []));
550
690
  const enforceHistorySize = (histArray) => this.limitByHistorySize(histArray, this.options.historySize);
551
691
  const enforceRedoStackSize = (redoArray) => this.limitByHistorySize(redoArray, this.options.historySize);
692
+ const monitorOptions = this.options.monitorOptions;
693
+ const monitorEnabled = Boolean(monitorOptions &&
694
+ ((monitorOptions.trackUpdates ?? true) || monitorOptions.trackPerformance));
695
+ const trackPerformance = Boolean(monitorOptions?.trackPerformance);
696
+ const signalName = monitorOptions?.label ||
697
+ this.options.debugLabel ||
698
+ this.options.storageKey ||
699
+ `signal-${SignalBuilder.monitorCounter++}`;
700
+ const createMiddlewareContext = (oldValue, newValue) => ({
701
+ signalName,
702
+ oldValue,
703
+ newValue,
704
+ timestamp: Date.now(),
705
+ });
552
706
  /**
553
707
  * Helper function to run async validation with debouncing and cancellation
554
708
  * @param value The value to validate
@@ -803,21 +957,20 @@ class SignalBuilder {
803
957
  }
804
958
  // Only update if value has changed
805
959
  if (hasChanged) {
806
- previousValue = conditionalClone(writable());
960
+ const oldValue = conditionalClone(writable());
961
+ spRunMiddleware(createMiddlewareContext(oldValue, conditionalClone(transformedValue)));
962
+ const monitorStart = monitorEnabled && trackPerformance ? performance.now() : 0;
963
+ previousValue = oldValue;
807
964
  writable.set(transformedValue);
808
- // Update history if enabled and value has changed
809
965
  if (this.options.enableHistory && !isProcessingDebounce) {
810
- // Clear redo stack when new value is set
811
966
  redoStack = [];
812
967
  const currentHistory = history();
813
968
  const newHistory = [
814
969
  ...currentHistory,
815
970
  conditionalClone(transformedValue),
816
971
  ];
817
- // Enforce history size limit using helper function
818
972
  history.set(enforceHistorySize(newHistory));
819
973
  }
820
- // Handle storage
821
974
  if (this.options.storageKey && isBrowser()) {
822
975
  try {
823
976
  isProcessingStorage = true;
@@ -835,7 +988,10 @@ class SignalBuilder {
835
988
  isProcessingStorage = false;
836
989
  }
837
990
  }
838
- // Only notify subscribers if not cleaned up
991
+ if (monitorEnabled) {
992
+ const duration = trackPerformance ? performance.now() - monitorStart : 0;
993
+ spMonitor.recordUpdate(signalName, duration);
994
+ }
839
995
  if (!isCleanedUp) {
840
996
  notifySubscribers(transformedValue);
841
997
  }
@@ -851,6 +1007,9 @@ class SignalBuilder {
851
1007
  if (this.options.debugLabel) {
852
1008
  spDebug.trackSignal(this.options.debugLabel, writable());
853
1009
  }
1010
+ if (monitorEnabled) {
1011
+ spMonitor.trackSignal(signalName);
1012
+ }
854
1013
  const processValue = (value) => {
855
1014
  // Prevent setValue after destroy
856
1015
  if (isCleanedUp) {
@@ -896,6 +1055,7 @@ class SignalBuilder {
896
1055
  }
897
1056
  }
898
1057
  catch (error) {
1058
+ spRunMiddlewareError(error, createMiddlewareContext(conditionalClone(writable()), conditionalClone(value)));
899
1059
  this.handleError(error);
900
1060
  throw error;
901
1061
  }
@@ -915,14 +1075,16 @@ class SignalBuilder {
915
1075
  set: processValue,
916
1076
  setValue: processValue,
917
1077
  update: (fn) => {
1078
+ let newValue;
918
1079
  try {
919
- const newValue = fn(writable());
920
- processValue(newValue);
1080
+ newValue = fn(writable());
921
1081
  }
922
1082
  catch (error) {
1083
+ spRunMiddlewareError(error, createMiddlewareContext(conditionalClone(writable()), conditionalClone(writable())));
923
1084
  this.handleError(error);
924
1085
  throw error;
925
1086
  }
1087
+ processValue(newValue);
926
1088
  },
927
1089
  reset: () => {
928
1090
  try {
@@ -4180,77 +4342,6 @@ function spComputed(fn, options = {}) {
4180
4342
  };
4181
4343
  }
4182
4344
 
4183
- const metrics = new Map();
4184
- let globalEnabled = true;
4185
- const ensureMetric = (name) => {
4186
- const existing = metrics.get(name);
4187
- if (existing) {
4188
- return existing;
4189
- }
4190
- const created = {
4191
- name,
4192
- updates: 0,
4193
- enabled: true,
4194
- totalDurationMs: 0,
4195
- averageDurationMs: 0,
4196
- maxDurationMs: 0,
4197
- lastDurationMs: 0,
4198
- lastUpdated: null,
4199
- };
4200
- metrics.set(name, created);
4201
- return created;
4202
- };
4203
- const spMonitor = {
4204
- trackSignal(name) {
4205
- ensureMetric(name);
4206
- },
4207
- recordUpdate(name, durationMs = 0) {
4208
- const metric = ensureMetric(name);
4209
- if (!globalEnabled || !metric.enabled) {
4210
- return;
4211
- }
4212
- metric.updates += 1;
4213
- metric.lastDurationMs = durationMs;
4214
- metric.totalDurationMs += durationMs;
4215
- metric.averageDurationMs = metric.totalDurationMs / metric.updates;
4216
- metric.maxDurationMs = Math.max(metric.maxDurationMs, durationMs);
4217
- metric.lastUpdated = Date.now();
4218
- },
4219
- enable(name) {
4220
- ensureMetric(name).enabled = true;
4221
- },
4222
- disable(name) {
4223
- ensureMetric(name).enabled = false;
4224
- },
4225
- enableAll() {
4226
- globalEnabled = true;
4227
- },
4228
- disableAll() {
4229
- globalEnabled = false;
4230
- },
4231
- getHotSignals(limit = 10) {
4232
- return Array.from(metrics.values())
4233
- .filter((metric) => metric.enabled)
4234
- .sort((a, b) => b.updates - a.updates)
4235
- .slice(0, limit)
4236
- .map((metric) => ({ ...metric }));
4237
- },
4238
- getSlowSignals(thresholdMs = 16) {
4239
- return Array.from(metrics.values())
4240
- .filter((metric) => metric.enabled && metric.averageDurationMs >= thresholdMs)
4241
- .sort((a, b) => b.averageDurationMs - a.averageDurationMs)
4242
- .map((metric) => ({ ...metric }));
4243
- },
4244
- exportMetrics(format = 'object') {
4245
- const exported = Array.from(metrics.values()).map((metric) => ({ ...metric }));
4246
- return format === 'json' ? JSON.stringify(exported) : exported;
4247
- },
4248
- clear() {
4249
- metrics.clear();
4250
- globalEnabled = true;
4251
- },
4252
- };
4253
-
4254
4345
  function spEffect(callback, options = {}) {
4255
4346
  const paused = signal(false, ...(ngDevMode ? [{ debugName: "paused" }] : []));
4256
4347
  let timeoutId = null;
@@ -4627,67 +4718,6 @@ function spFormGroup(config, options) {
4627
4718
  };
4628
4719
  }
4629
4720
 
4630
- /**
4631
- * Middleware system for intercepting signal operations.
4632
- */
4633
- const middlewareRegistry = [];
4634
- function spUseMiddleware(middleware) {
4635
- if (!middlewareRegistry.some((m) => m.name === middleware.name)) {
4636
- middlewareRegistry.push(middleware);
4637
- }
4638
- }
4639
- function spRemoveMiddleware(name) {
4640
- const index = middlewareRegistry.findIndex((m) => m.name === name);
4641
- if (index === -1)
4642
- return false;
4643
- middlewareRegistry.splice(index, 1);
4644
- return true;
4645
- }
4646
- function spClearMiddleware() {
4647
- middlewareRegistry.length = 0;
4648
- }
4649
- function spGetMiddlewareCount() {
4650
- return middlewareRegistry.length;
4651
- }
4652
- function spRunMiddleware(context) {
4653
- for (const m of middlewareRegistry) {
4654
- try {
4655
- m.onSet?.(context);
4656
- }
4657
- catch {
4658
- /* ignore */
4659
- }
4660
- }
4661
- }
4662
- function spRunMiddlewareError(error, context) {
4663
- for (const m of middlewareRegistry) {
4664
- try {
4665
- m.onError?.(error, context);
4666
- }
4667
- catch {
4668
- /* ignore */
4669
- }
4670
- }
4671
- }
4672
- function spLoggerMiddleware(prefix = '[Signal]') {
4673
- return {
4674
- name: 'sp-logger',
4675
- onSet: (ctx) => console.log(`${prefix} ${ctx.signalName || 'signal'}: ${JSON.stringify(ctx.oldValue)} -> ${JSON.stringify(ctx.newValue)}`),
4676
- onError: (error, ctx) => console.error(`${prefix} Error in ${ctx.signalName || 'signal'}:`, error.message),
4677
- };
4678
- }
4679
- function spAnalyticsMiddleware(tracker) {
4680
- return {
4681
- name: 'sp-analytics',
4682
- onSet: (ctx) => tracker({
4683
- name: ctx.signalName || 'unknown',
4684
- oldValue: ctx.oldValue,
4685
- newValue: ctx.newValue,
4686
- timestamp: ctx.timestamp,
4687
- }),
4688
- };
4689
- }
4690
-
4691
4721
  function extractErrorMessages(error) {
4692
4722
  if (!error) {
4693
4723
  return [];