@alwatr/signal 9.26.0 → 9.30.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 (52) hide show
  1. package/README.md +197 -603
  2. package/dist/core/channel-signal.d.ts +12 -53
  3. package/dist/core/channel-signal.d.ts.map +1 -1
  4. package/dist/core/computed-signal.d.ts +19 -33
  5. package/dist/core/computed-signal.d.ts.map +1 -1
  6. package/dist/core/derived-signal.d.ts +71 -0
  7. package/dist/core/derived-signal.d.ts.map +1 -0
  8. package/dist/core/effect-signal.d.ts +15 -1
  9. package/dist/core/effect-signal.d.ts.map +1 -1
  10. package/dist/core/event-signal.d.ts +11 -4
  11. package/dist/core/event-signal.d.ts.map +1 -1
  12. package/dist/core/persistent-state-signal.d.ts +21 -2
  13. package/dist/core/persistent-state-signal.d.ts.map +1 -1
  14. package/dist/core/session-state-signal.d.ts +19 -2
  15. package/dist/core/session-state-signal.d.ts.map +1 -1
  16. package/dist/core/signal-base.d.ts +58 -38
  17. package/dist/core/signal-base.d.ts.map +1 -1
  18. package/dist/core/state-signal.d.ts +33 -14
  19. package/dist/core/state-signal.d.ts.map +1 -1
  20. package/dist/creators/channel.d.ts +1 -1
  21. package/dist/creators/channel.d.ts.map +1 -1
  22. package/dist/creators/derived.d.ts +31 -0
  23. package/dist/creators/derived.d.ts.map +1 -0
  24. package/dist/main.d.ts +2 -1
  25. package/dist/main.d.ts.map +1 -1
  26. package/dist/main.js +3 -3
  27. package/dist/main.js.map +16 -15
  28. package/dist/operators/debounce.d.ts +2 -3
  29. package/dist/operators/debounce.d.ts.map +1 -1
  30. package/dist/operators/filter.d.ts +14 -13
  31. package/dist/operators/filter.d.ts.map +1 -1
  32. package/dist/type.d.ts +68 -3
  33. package/dist/type.d.ts.map +1 -1
  34. package/package.json +6 -6
  35. package/src/core/channel-signal.ts +25 -68
  36. package/src/core/computed-signal.ts +50 -74
  37. package/src/core/derived-signal.ts +166 -0
  38. package/src/core/effect-signal.ts +23 -11
  39. package/src/core/event-signal.ts +14 -9
  40. package/src/core/persistent-state-signal.ts +21 -4
  41. package/src/core/session-state-signal.ts +19 -4
  42. package/src/core/signal-base.ts +98 -61
  43. package/src/core/state-signal.ts +48 -29
  44. package/src/creators/channel.ts +1 -2
  45. package/src/creators/derived.ts +34 -0
  46. package/src/main.ts +2 -1
  47. package/src/operators/debounce.ts +13 -23
  48. package/src/operators/filter.ts +20 -26
  49. package/src/type.ts +71 -3
  50. package/dist/operators/map.d.ts +0 -36
  51. package/dist/operators/map.d.ts.map +0 -1
  52. package/src/operators/map.ts +0 -48
@@ -0,0 +1,166 @@
1
+ import {createLogger, type AlwatrLogger} from '@alwatr/logger';
2
+ import {StateSignal} from './state-signal.js';
3
+ import type {
4
+ IReadonlySignal,
5
+ DerivedSignalConfig,
6
+ ListenerCallback,
7
+ SubscribeOptions,
8
+ SubscribeResult,
9
+ } from '../type.js';
10
+
11
+ /**
12
+ * Ultra-performance read-only signal mapping exactly 1-to-1 over a single upstream source.
13
+ *
14
+ * COMPOSITION DESIGN PATTERN (HAS-A):
15
+ * Instead of extending the heavyweight Base class and duplicating tracking structures, it wraps
16
+ * an internal StateSignal instance. It features a "Cold Awakening Lifecycle": it consumes exactly
17
+ * ZERO stream overhead from the source until it receives its own first consumer subscription.
18
+ * If all consumers disconnect, it goes back to sleep (hibernation phase) to save performance.
19
+ *
20
+ * @template S The type of the source signal state.
21
+ * @template T The type of the derived/projected signal state.
22
+ */
23
+ export class DerivedSignal<S, T> implements IReadonlySignal<T> {
24
+ /** The unique identifier for this signal instance, useful for debugging and tracing. */
25
+ public readonly name: string;
26
+
27
+ /** Scoped logger for tracking derived operations. */
28
+ protected readonly logger_: AlwatrLogger;
29
+
30
+ /** Wrapped internal state carrier - Enforcing COMPOSITION over inheritance, allocated lazily */
31
+ protected internalSignal_?: StateSignal<T> | null;
32
+
33
+ /** Subscription handle to the upstream source signal, active only when awake. */
34
+ private sourceSubscription__?: SubscribeResult;
35
+
36
+ /** Number of active standard/priority listeners currently subscribed to this signal. */
37
+ private activeConsumerCount__ = 0;
38
+
39
+ /**
40
+ * Creates a new DerivedSignal instance.
41
+ *
42
+ * @param config_ Configuration options including name, source, and projector.
43
+ */
44
+ constructor(protected config_: DerivedSignalConfig<S, T>) {
45
+ this.name = this.config_.name;
46
+ this.logger_ = createLogger(`derived_signal:${this.name}`);
47
+ }
48
+
49
+ untilNext(): Promise<T> {
50
+ this.logger_.logMethod?.('untilNext');
51
+ this.checkDestroyed__();
52
+ return new Promise<T>((resolve) => {
53
+ this.subscribe(
54
+ (value) => {
55
+ resolve(value);
56
+ },
57
+ {receivePrevious: false, once: true},
58
+ );
59
+ });
60
+ }
61
+
62
+ /**
63
+ * Retrieves the current value of the derived signal.
64
+ *
65
+ * If there are no active subscribers (cold state), it re-computes dynamically
66
+ * on demand to ensure strict data freshness.
67
+ *
68
+ * @returns The current projected value.
69
+ */
70
+ public get(): T {
71
+ this.logger_.logMethod?.('get');
72
+ this.checkDestroyed__();
73
+ if (this.activeConsumerCount__ === 0) {
74
+ return this.config_.projector(this.config_.source.get());
75
+ }
76
+ return this.internalSignal_!.get();
77
+ }
78
+
79
+ /**
80
+ * Indicates whether the signal has been destroyed.
81
+ */
82
+ public get isDestroyed(): boolean {
83
+ return this.config_ === null;
84
+ }
85
+
86
+ /**
87
+ * Subscribes a listener to updates of this derived signal.
88
+ *
89
+ * In case of first subscription, it triggers the "Cold Awakening Lifecycle"
90
+ * to subscribe to the source signal.
91
+ *
92
+ * @param callback Subscription callback function.
93
+ * @param options Subscription configurations.
94
+ * @returns Unsubscribe handle object.
95
+ */
96
+ public subscribe(callback: ListenerCallback<T>, options?: SubscribeOptions): SubscribeResult {
97
+ this.logger_.logMethod?.('subscribe');
98
+ this.checkDestroyed__();
99
+ this.activeConsumerCount__++;
100
+
101
+ // Wake-up phase: if this is the first active consumer, dynamically clamp to the upstream core source
102
+ if (this.activeConsumerCount__ === 1) {
103
+ this.logger_.logMethod?.('wakeUp_');
104
+ this.internalSignal_ = new StateSignal<T>({
105
+ name: `derived-internal:${this.name}`,
106
+ initialValue: this.config_.projector(this.config_.source.get()),
107
+ });
108
+ this.sourceSubscription__ = this.config_.source.subscribe(
109
+ (newValue) => {
110
+ this.internalSignal_!.set(this.config_.projector(newValue));
111
+ },
112
+ {receivePrevious: false},
113
+ );
114
+ }
115
+
116
+ const sub = this.internalSignal_!.subscribe(callback, options);
117
+
118
+ return {
119
+ unsubscribe: () => {
120
+ this.logger_.logMethod?.('unsubscribe');
121
+
122
+ sub.unsubscribe();
123
+ this.activeConsumerCount__--;
124
+
125
+ // Hibernation phase: unlink tracking dependencies when view elements clear out to preserve processing cycles
126
+ if (this.activeConsumerCount__ === 0 && this.sourceSubscription__) {
127
+ this.logger_.logMethod?.('sleepCleanup_');
128
+ this.sourceSubscription__.unsubscribe();
129
+ this.sourceSubscription__ = undefined;
130
+ this.internalSignal_?.destroy();
131
+ this.internalSignal_ = undefined;
132
+ }
133
+ },
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Destroys the derived signal and unsubscribes from the source signal if currently awake.
139
+ */
140
+ public destroy(): void {
141
+ this.logger_.logMethod?.('destroy');
142
+ if (this.isDestroyed) return;
143
+
144
+ if (this.sourceSubscription__) {
145
+ this.sourceSubscription__.unsubscribe();
146
+ this.sourceSubscription__ = undefined;
147
+ }
148
+
149
+ this.internalSignal_?.destroy();
150
+ this.config_.onDestroy?.();
151
+ this.config_ = null as unknown as DerivedSignalConfig<S, T>;
152
+ }
153
+
154
+ /**
155
+ * Checks if the signal has been destroyed.
156
+ *
157
+ * @private
158
+ * @throws {Error} If destroyed.
159
+ */
160
+ private checkDestroyed__(): void {
161
+ this.logger_.logMethod?.('checkDestroyed__');
162
+ if (this.isDestroyed) {
163
+ throw new Error(`Cannot interact with a destroyed signal (id: ${this.name})`);
164
+ }
165
+ }
166
+ }
@@ -1,7 +1,6 @@
1
- import { delay } from '@alwatr/delay';
2
- import { createLogger, type AlwatrLogger } from '@alwatr/logger';
3
-
4
- import type { EffectSignalConfig, IEffectSignal, SubscribeResult } from '../type.js';
1
+ import {delay} from '@alwatr/delay';
2
+ import {createLogger, type AlwatrLogger} from '@alwatr/logger';
3
+ import type {EffectSignalConfig, IEffectSignal, SubscribeResult} from '../type.js';
5
4
 
6
5
  /**
7
6
  * Manages a side-effect that runs in response to changes in dependency signals.
@@ -50,24 +49,30 @@ export class EffectSignal implements IEffectSignal {
50
49
 
51
50
  /**
52
51
  * The logger instance for this signal.
52
+ *
53
53
  * @protected
54
54
  */
55
55
  protected readonly logger_: AlwatrLogger;
56
56
 
57
57
  /**
58
58
  * A list of subscriptions to dependency signals.
59
+ * Used to unsubscribe from dependencies when this signal is destroyed.
60
+ *
59
61
  * @private
60
62
  */
61
63
  private readonly dependencySubscriptions__: SubscribeResult[] = [];
62
64
 
63
65
  /**
64
66
  * A flag to prevent concurrent executions of the effect.
67
+ * Avoids scheduling multiple runs within the same event loop.
68
+ *
65
69
  * @private
66
70
  */
67
71
  private isRunning__ = false;
68
72
 
69
73
  /**
70
74
  * A flag indicating whether the effect has been destroyed.
75
+ *
71
76
  * @private
72
77
  */
73
78
  private isDestroyed__ = false;
@@ -82,6 +87,12 @@ export class EffectSignal implements IEffectSignal {
82
87
  return this.isDestroyed__;
83
88
  }
84
89
 
90
+ /**
91
+ * Creates a new EffectSignal instance.
92
+ * Subscribes to all dependency signals to listen for updates.
93
+ *
94
+ * @param config_ Configuration options including dependencies, side-effect runner callback, and immediate execution flag.
95
+ */
85
96
  constructor(protected config_: EffectSignalConfig) {
86
97
  this.name = config_.name ?? `[${config_.deps.map((dep) => dep.name).join(', ')}]`;
87
98
  this.logger_ = createLogger(`effect-signal:${this.name}`);
@@ -92,8 +103,8 @@ export class EffectSignal implements IEffectSignal {
92
103
  // Subscribe to all dependencies. We don't need the previous value,
93
104
  // as the `runImmediately` option controls the initial execution.
94
105
  for (const signal of config_.deps) {
95
- this.logger_.logStep?.('constructor', 'subscribing_to_dependency', { signal: signal.name });
96
- this.dependencySubscriptions__.push(signal.subscribe(this.scheduleExecution_, { receivePrevious: false }));
106
+ this.logger_.logStep?.('constructor', 'subscribing_to_dependency', {signal: signal.name});
107
+ this.dependencySubscriptions__.push(signal.subscribe(this.scheduleExecution_, {receivePrevious: false}));
97
108
  }
98
109
 
99
110
  // Run the effect immediately if requested.
@@ -107,10 +118,12 @@ export class EffectSignal implements IEffectSignal {
107
118
  /**
108
119
  * Schedules the execution of the effect's `run` function.
109
120
  *
110
- * This method batches updates using a macrotask (`delay.nextMacrotask`) to ensure the
121
+ * This method batches updates using a macrotask (`delay.nextMicrotask`) to ensure the
111
122
  * `run` function executes only once per event loop tick, even if multiple
112
123
  * dependencies change simultaneously.
124
+ *
113
125
  * @protected
126
+ * @returns A promise that resolves when the execution schedules or runs.
114
127
  */
115
128
  protected async scheduleExecution_(): Promise<void> {
116
129
  this.logger_.logMethod?.('scheduleExecution_');
@@ -129,7 +142,7 @@ export class EffectSignal implements IEffectSignal {
129
142
 
130
143
  try {
131
144
  // Wait for the next macrotask to batch simultaneous updates.
132
- await delay.nextMacrotask();
145
+ await delay.nextMicrotask();
133
146
  if (this.isDestroyed__) {
134
147
  this.logger_.incident?.('scheduleExecution_', 'destroyed_during_delay');
135
148
  this.isRunning__ = false;
@@ -137,9 +150,8 @@ export class EffectSignal implements IEffectSignal {
137
150
  }
138
151
 
139
152
  this.logger_.logStep?.('scheduleExecution_', 'executing_effect');
140
- await this.config_.run();
141
- }
142
- catch (err) {
153
+ this.config_.run();
154
+ } catch (err) {
143
155
  this.logger_.error('scheduleExecution_', 'effect_failed', err);
144
156
  }
145
157
 
@@ -1,8 +1,6 @@
1
- import {delay} from '@alwatr/delay';
1
+ import {queueMicrotask} from '@alwatr/delay';
2
2
  import {createLogger, type AlwatrLogger} from '@alwatr/logger';
3
-
4
3
  import {SignalBase} from './signal-base.js';
5
-
6
4
  import type {IBaseSignal, SignalConfig} from '../type.js';
7
5
 
8
6
  /**
@@ -35,27 +33,34 @@ import type {IBaseSignal, SignalConfig} from '../type.js';
35
33
  export class EventSignal<T = void> extends SignalBase<T> implements IBaseSignal<T> {
36
34
  /**
37
35
  * The logger instance for this signal.
36
+ *
38
37
  * @protected
39
38
  */
40
39
  protected logger_: AlwatrLogger;
41
40
 
41
+ /**
42
+ * Creates a new EventSignal instance.
43
+ *
44
+ * @param config Configuration options including the unique event name and custom cleanup hooks.
45
+ */
42
46
  constructor(config: SignalConfig) {
43
47
  super(config);
44
- this.logger_ = createLogger(`event-signal:${this.name}`);
48
+ this.logger_ = createLogger(`event_signal:${this.name}`);
45
49
  this.logger_.logMethod?.('constructor');
46
50
  }
47
51
 
48
52
  /**
49
- * Dispatches an event with an optional payload to all active listeners.
50
- * The notification is scheduled as a microtask to prevent blocking and ensure
51
- * a consistent, non-blocking flow.
53
+ * Dispatches an event with the specified payload to all active listeners.
54
+ *
55
+ * To prevent blocking of the main thread and ensure consistent execution order,
56
+ * the notification execution is scheduled as a microtask using `queueMicrotask`.
52
57
  *
53
- * @param payload The data to send with the event.
58
+ * @param payload The data payload to send with the event.
54
59
  */
55
60
  public dispatch(payload: T): void {
56
61
  this.logger_.logMethodArgs?.('dispatch', {payload});
57
62
  this.checkDestroyed_();
58
63
  // Dispatch as a microtask to ensure consistent, non-blocking behavior.
59
- delay.nextMicrotask().then(() => this.notify_(payload));
64
+ queueMicrotask(() => this.notify_(payload));
60
65
  }
61
66
  }
@@ -1,8 +1,6 @@
1
1
  import {createDebouncer} from '@alwatr/debounce';
2
2
  import {createLocalStorageProvider} from '@alwatr/local-storage';
3
-
4
3
  import {StateSignal} from './state-signal.js';
5
-
6
4
  import type {PersistentStateSignalConfig} from '../type.js';
7
5
  import type {LocalStorageProvider} from '@alwatr/local-storage';
8
6
 
@@ -50,12 +48,16 @@ import type {LocalStorageProvider} from '@alwatr/local-storage';
50
48
  export class PersistentStateSignal<T> extends StateSignal<T> {
51
49
  /**
52
50
  * The underlying storage provider instance.
51
+ * Handles read, write, and schema version management under the hood.
52
+ *
53
53
  * @private
54
54
  */
55
55
  private readonly storageProvider__: LocalStorageProvider<T>;
56
56
 
57
57
  /**
58
58
  * Debouncer to limit how often we write to localStorage.
59
+ * Reduces performance overhead from excessive disk writes.
60
+ *
59
61
  * @private
60
62
  */
61
63
  private readonly storageDebouncer__;
@@ -64,12 +66,15 @@ export class PersistentStateSignal<T> extends StateSignal<T> {
64
66
  * The subscription to the signal's own changes to sync with storage.
65
67
  * We subscribe to our own signal. When the value is set from anywhere,
66
68
  * this listener will trigger and write it to localStorage.
69
+ *
67
70
  * @private
68
71
  */
69
72
  private readonly storageSyncSubscription__;
70
73
 
71
74
  /**
72
75
  * Listener for the browser's pagehide events to flush pending saves.
76
+ * Ensures that pending changes are saved before the page unloading.
77
+ *
73
78
  * @private
74
79
  */
75
80
  private readonly windowPageHideListener_ = (): void => {
@@ -78,6 +83,8 @@ export class PersistentStateSignal<T> extends StateSignal<T> {
78
83
 
79
84
  /**
80
85
  * Listener for the browser's pageshow events to sync from storage when restored from BFCache.
86
+ * Refreshes the in-memory value if retrieved from the Back/Forward Cache.
87
+ *
81
88
  * @private
82
89
  */
83
90
  private readonly windowPageShowListener_ = (event: PageTransitionEvent): void => {
@@ -90,6 +97,13 @@ export class PersistentStateSignal<T> extends StateSignal<T> {
90
97
  }
91
98
  };
92
99
 
100
+ /**
101
+ * Creates a new PersistentStateSignal instance.
102
+ * Restores initial value from storage if it exists, otherwise uses default initialValue.
103
+ * Sets up window page visibility and BFCache listeners to guarantee write flushes.
104
+ *
105
+ * @param config Configuration options including storage keys, debounce delays, schema, parse, and stringify overrides.
106
+ */
93
107
  constructor(config: PersistentStateSignalConfig<T>) {
94
108
  const {
95
109
  name,
@@ -137,7 +151,10 @@ export class PersistentStateSignal<T> extends StateSignal<T> {
137
151
 
138
152
  /**
139
153
  * Syncs the new value to storage.
140
- * @param newValue The new value to sync to storage.
154
+ * Invoked automatically by the debouncer.
155
+ *
156
+ * @param newValue The new value to write to storage.
157
+ * @private
141
158
  */
142
159
  private syncStorage__(newValue: T): void {
143
160
  this.logger_.logMethodArgs?.('syncStorage__', newValue);
@@ -156,7 +173,7 @@ export class PersistentStateSignal<T> extends StateSignal<T> {
156
173
  }
157
174
 
158
175
  /**
159
- * Overrides the destroy method to also clean up the storage sync subscription.
176
+ * Overrides the destroy method to also clean up the storage sync subscription and event listeners.
160
177
  */
161
178
  public override destroy(): void {
162
179
  this.logger_.logMethod?.('destroy');
@@ -1,8 +1,6 @@
1
1
  import {createDebouncer} from '@alwatr/debounce';
2
2
  import {createSessionStorageProvider} from '@alwatr/session-storage';
3
-
4
3
  import {StateSignal} from './state-signal.js';
5
-
6
4
  import type {SessionStateSignalConfig} from '../type.js';
7
5
  import type {SessionStorageProvider} from '@alwatr/session-storage';
8
6
 
@@ -44,7 +42,7 @@ import type {SessionStorageProvider} from '@alwatr/session-storage';
44
42
  *
45
43
  * // Example 2: Custom state type with parse and stringify
46
44
  * const dateSignal = new SessionStateSignal<Date>({
47
- * name: 'last-interaction',
45
+ * name: 'timestamp-signal',
48
46
  * initialValue: new Date(),
49
47
  * parse: (str: string) => new Date(str),
50
48
  * stringify: (date: Date) => date.toISOString(),
@@ -62,24 +60,31 @@ import type {SessionStorageProvider} from '@alwatr/session-storage';
62
60
  export class SessionStateSignal<T> extends StateSignal<T> {
63
61
  /**
64
62
  * The underlying session storage provider instance.
63
+ *
65
64
  * @private
66
65
  */
67
66
  private readonly storageProvider__: SessionStorageProvider<T>;
68
67
 
69
68
  /**
70
69
  * Debouncer to limit how often we write to sessionStorage.
70
+ * Helps reduce synchronous overhead from session storage updates.
71
+ *
71
72
  * @private
72
73
  */
73
74
  private readonly storageDebouncer__;
74
75
 
75
76
  /**
76
77
  * Subscription to the signal's own changes for sessionStorage sync.
78
+ * Writes the value to sessionStorage after a debounce delay.
79
+ *
77
80
  * @private
78
81
  */
79
82
  private readonly storageSyncSubscription__;
80
83
 
81
84
  /**
82
85
  * Listener for the browser's pagehide events to flush pending saves.
86
+ * Ensures pending state changes are flushed to sessionStorage before leaving the page.
87
+ *
83
88
  * @private
84
89
  */
85
90
  private readonly windowPageHideListener__ = (): void => {
@@ -88,6 +93,8 @@ export class SessionStateSignal<T> extends StateSignal<T> {
88
93
 
89
94
  /**
90
95
  * Listener for the browser's pageshow events to sync from storage when restored from BFCache.
96
+ * Ensures in-memory sync when restored from the back-forward cache.
97
+ *
91
98
  * @private
92
99
  */
93
100
  private readonly windowPageShowListener__ = (event: PageTransitionEvent): void => {
@@ -100,6 +107,13 @@ export class SessionStateSignal<T> extends StateSignal<T> {
100
107
  }
101
108
  };
102
109
 
110
+ /**
111
+ * Creates a new SessionStateSignal instance.
112
+ * Loads initial value from sessionStorage if found, otherwise uses config's initialValue.
113
+ * Sets up page hide/show lifecycle handlers.
114
+ *
115
+ * @param config Configuration options including storageKeys, custom parse/stringify, and debounce delay.
116
+ */
103
117
  constructor(config: SessionStateSignalConfig<T>) {
104
118
  const {name, storageKey = name, saveDebounceDelay = 1000, initialValue, onDestroy, parse, stringify} = config;
105
119
 
@@ -140,6 +154,7 @@ export class SessionStateSignal<T> extends StateSignal<T> {
140
154
  * Called automatically by the debouncer after each state change.
141
155
  *
142
156
  * @param newValue The new value to sync.
157
+ * @private
143
158
  */
144
159
  private syncStorage__(newValue: T): void {
145
160
  this.logger_.logMethodArgs?.('syncStorage__', newValue);
@@ -165,7 +180,7 @@ export class SessionStateSignal<T> extends StateSignal<T> {
165
180
  }
166
181
 
167
182
  /**
168
- * Destroys the signal, flushing pending writes and cleaning up all resources.
183
+ * Destroys the signal, flushing pending writes and cleaning up all resources and events.
169
184
  *
170
185
  * Always call this when the signal is no longer needed (e.g., on component unmount)
171
186
  * to prevent memory leaks.