@alwatr/signal 6.2.0 → 9.1.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 (42) hide show
  1. package/dist/core/computed-signal.d.ts +2 -1
  2. package/dist/core/computed-signal.d.ts.map +1 -1
  3. package/dist/core/effect-signal.d.ts +2 -1
  4. package/dist/core/effect-signal.d.ts.map +1 -1
  5. package/dist/core/event-signal.d.ts +2 -1
  6. package/dist/core/event-signal.d.ts.map +1 -1
  7. package/dist/core/signal-base.d.ts.map +1 -1
  8. package/dist/core/state-signal.d.ts +2 -1
  9. package/dist/core/state-signal.d.ts.map +1 -1
  10. package/dist/main.js +5 -0
  11. package/dist/main.js.map +25 -0
  12. package/dist/operators/debounce.d.ts.map +1 -1
  13. package/dist/type.d.ts.map +1 -1
  14. package/package.json +48 -53
  15. package/src/core/computed-signal.ts +216 -0
  16. package/src/core/effect-signal.ts +176 -0
  17. package/src/core/event-signal.ts +61 -0
  18. package/src/core/persistent-state-signal.ts +98 -0
  19. package/src/core/session-state-signal.ts +145 -0
  20. package/src/core/signal-base.ts +191 -0
  21. package/src/core/state-signal.ts +178 -0
  22. package/src/creators/computed.ts +37 -0
  23. package/src/creators/effect.ts +46 -0
  24. package/src/creators/event.ts +31 -0
  25. package/src/creators/persistent-state.ts +33 -0
  26. package/src/creators/session-state.ts +42 -0
  27. package/src/creators/state.ts +28 -0
  28. package/src/main.ts +20 -0
  29. package/src/operators/debounce.ts +91 -0
  30. package/src/operators/filter.ts +78 -0
  31. package/src/operators/map.ts +48 -0
  32. package/src/type.ts +357 -0
  33. package/CHANGELOG.md +0 -908
  34. package/dist/main.cjs +0 -4
  35. package/dist/main.cjs.map +0 -7
  36. package/dist/main.mjs +0 -4
  37. package/dist/main.mjs.map +0 -7
  38. package/src/core/computed-signal.test.js +0 -166
  39. package/src/core/effect-signal.test.js +0 -150
  40. package/src/core/event-signal.test.js +0 -210
  41. package/src/core/state-signal.test.js +0 -251
  42. package/src/operators/debounce.test.js +0 -206
@@ -0,0 +1,216 @@
1
+ import { delay } from '@alwatr/delay';
2
+ import { createLogger, type AlwatrLogger } from '@alwatr/logger';
3
+
4
+ import { StateSignal } from './state-signal.js';
5
+
6
+ import type { ComputedSignalConfig, IReadonlySignal, SubscribeResult, SubscribeOptions } from '../type.js';
7
+
8
+ /**
9
+ * A read-only signal that derives its value from a set of dependency signals.
10
+ *
11
+ * `ComputedSignal` is a powerful tool for creating values that reactively update when their underlying
12
+ * data sources change. Its value is memoized, meaning the `get` function is only re-evaluated when
13
+ * one of its dependencies has actually changed.
14
+ *
15
+ * A key feature is its lifecycle management: a `ComputedSignal` **must** be destroyed when no longer
16
+ * needed to prevent memory leaks from its subscriptions to dependency signals.
17
+ *
18
+ * @template T The type of the computed value.
19
+ *
20
+ * @example
21
+ * // --- Create dependency signals ---
22
+ * const firstName = new StateSignal({ name: 'firstName', initialValue: 'John' });
23
+ * const lastName = new StateSignal({ name: 'lastName', initialValue: 'Doe' });
24
+ *
25
+ * // --- Create a computed signal ---
26
+ * const fullName = new ComputedSignal({
27
+ * name: 'fullName',
28
+ * deps: [firstName, lastName],
29
+ * get: () => `${firstName.get()} ${lastName.get()}`,
30
+ * });
31
+ *
32
+ * console.log(fullName.get()); // Outputs: "John Doe"
33
+ *
34
+ * // --- Subscribe to the computed value ---
35
+ * fullName.subscribe(newFullName => {
36
+ * console.log(`Name changed to: ${newFullName}`);
37
+ * });
38
+ *
39
+ * // --- Update a dependency ---
40
+ * lastName.set('Smith'); // Recalculates and logs: "Name changed to: John Smith"
41
+ * console.log(fullName.get()); // Outputs: "John Smith"
42
+ *
43
+ * // --- IMPORTANT: Clean up when done ---
44
+ * fullName.destroy();
45
+ */
46
+ export class ComputedSignal<T> implements IReadonlySignal<T> {
47
+ /**
48
+ * The unique identifier for this signal instance.
49
+ */
50
+ public readonly name: string;
51
+
52
+ /**
53
+ * The logger instance for this signal.
54
+ * @protected
55
+ */
56
+ protected readonly logger_: AlwatrLogger;
57
+
58
+ /**
59
+ * The internal `StateSignal` that holds the computed value.
60
+ * This is how the computed signal provides `.get()` and `.subscribe()` methods.
61
+ * @protected
62
+ */
63
+ protected readonly internalSignal_: StateSignal<T>;
64
+
65
+ /**
66
+ * A list of subscriptions to dependency signals.
67
+ * @private
68
+ */
69
+
70
+ private readonly dependencySubscriptions__: SubscribeResult[] = [];
71
+
72
+ /**
73
+ * A flag to prevent concurrent recalculations.
74
+ * @private
75
+ */
76
+ private isRecalculating__ = false;
77
+
78
+ constructor(protected config_: ComputedSignalConfig<T>) {
79
+ this.name = config_.name;
80
+ this.logger_ = createLogger(`computed-signal:${this.name}`);
81
+ this.recalculate_ = this.recalculate_.bind(this);
82
+
83
+ this.logger_.logMethod?.('constructor');
84
+
85
+ this.internalSignal_ = new StateSignal<T>({
86
+ name: `compute-${this.name}_`,
87
+ initialValue: this.config_.get(),
88
+ });
89
+
90
+ // Subscribe to all dependencies to trigger recalculation on change.
91
+ for (const signal of config_.deps) {
92
+ this.logger_.logStep?.('constructor', 'subscribing_to_dependency', { signal: signal.name });
93
+ this.dependencySubscriptions__.push(signal.subscribe(this.recalculate_, { receivePrevious: false }));
94
+ }
95
+ }
96
+
97
+ /**
98
+ * The current value of the computed signal.
99
+ * Accessing this property returns the memoized value and does not trigger a recalculation.
100
+ *
101
+ * @returns The current computed value.
102
+ * @throws {Error} If accessed after the signal has been destroyed.
103
+ */
104
+ public get(): T {
105
+ return this.internalSignal_.get();
106
+ }
107
+
108
+ /**
109
+ * Indicates whether the computed signal has been destroyed.
110
+ * A destroyed signal cannot be used and will throw an error if interacted with.
111
+ * @returns `true` if the signal is destroyed, `false` otherwise.
112
+ */
113
+ public get isDestroyed(): boolean {
114
+ return this.internalSignal_.isDestroyed;
115
+ }
116
+
117
+ /**
118
+ * Subscribes a listener to this signal.
119
+ * The listener will be called whenever the computed value changes.
120
+ *
121
+ * @param callback The function to be called with the new value.
122
+ * @param options Subscription options.
123
+ * @returns A `SubscribeResult` object with an `unsubscribe` method.
124
+ */
125
+ public subscribe(callback: (value: T) => void, options?: SubscribeOptions): SubscribeResult {
126
+ return this.internalSignal_.subscribe(callback, options);
127
+ }
128
+
129
+ /**
130
+ * Returns a Promise that resolves with the next computed value.
131
+ *
132
+ * @returns A Promise that resolves with the next value.
133
+ */
134
+ public untilNext(): Promise<T> {
135
+ return this.internalSignal_.untilNext();
136
+ }
137
+
138
+ /**
139
+ * Permanently disposes of the computed signal.
140
+ *
141
+ * This is a critical cleanup step. It unsubscribes from all dependency signals,
142
+ * stopping future recalculations and allowing the signal to be garbage collected.
143
+ * Failure to call `destroy()` will result in memory leaks.
144
+ *
145
+ * After `destroy()` is called, any attempt to access `.get()` or `.subscribe()` will throw an error.
146
+ */
147
+ public destroy(): void {
148
+ this.logger_.logMethod?.('destroy');
149
+ /**
150
+ * If already destroyed, log an incident and return early.
151
+ */
152
+ if (this.isDestroyed) {
153
+ this.logger_.incident?.('destroy', 'already_destroyed');
154
+ return;
155
+ }
156
+
157
+ // Unsubscribe from all upstream dependencies.
158
+ for (const subscription of this.dependencySubscriptions__) {
159
+ subscription.unsubscribe();
160
+ }
161
+ this.dependencySubscriptions__.length = 0; // Clear the array of subscriptions.
162
+
163
+ this.internalSignal_.destroy(); // Destroy the internal signal.
164
+ this.config_.onDestroy?.(); // Call the optional onDestroy callback.
165
+ this.config_ = null as unknown as ComputedSignalConfig<T>; // Release config closure.
166
+ }
167
+
168
+ /**
169
+ * Schedules a recalculation of the signal's value.
170
+ *
171
+ * This method batches updates using a macrotask (`delay.nextMacrotask`) to ensure the
172
+ * `get` function runs only once per event loop tick, even if multiple dependencies
173
+ * change in the same synchronous block of code.
174
+ * @protected
175
+ */
176
+ protected async recalculate_(): Promise<void> {
177
+ this.logger_.logMethod?.('recalculate_');
178
+
179
+ if (this.internalSignal_.isDestroyed) {
180
+ // This check is important in case a dependency fires after this signal is destroyed.
181
+ this.logger_.incident?.('recalculate_', 'recalculate_on_destroyed_signal');
182
+ return;
183
+ }
184
+
185
+ if (this.isRecalculating__) {
186
+ // If a recalculation is already scheduled, do nothing.
187
+ this.logger_.logStep?.('recalculate_', 'skipping_recalculation_already_scheduled');
188
+ return;
189
+ }
190
+
191
+ this.isRecalculating__ = true;
192
+
193
+ try {
194
+ // Wait for the next macrotask to start the recalculation.
195
+ // This batches all synchronous dependency updates in the current event loop.
196
+ await delay.nextMacrotask();
197
+
198
+ if (this.isDestroyed) {
199
+ this.logger_.incident?.('recalculate_', 'destroyed_during_delay');
200
+ this.isRecalculating__ = false;
201
+ return;
202
+ }
203
+
204
+ this.logger_.logStep?.('recalculate_', 'recalculating_value');
205
+
206
+ // Set the new value on the internal signal, which will notify our subscribers.
207
+ this.internalSignal_.set(this.config_.get());
208
+ }
209
+ catch (err) {
210
+ this.logger_.error('recalculate_', 'recalculation_failed', err);
211
+ }
212
+
213
+ // Allow the next recalculation to be scheduled.
214
+ this.isRecalculating__ = false;
215
+ }
216
+ }
@@ -0,0 +1,176 @@
1
+ import { delay } from '@alwatr/delay';
2
+ import { createLogger, type AlwatrLogger } from '@alwatr/logger';
3
+
4
+ import type { EffectSignalConfig, IEffectSignal, SubscribeResult } from '../type.js';
5
+
6
+ /**
7
+ * Manages a side-effect that runs in response to changes in dependency signals.
8
+ *
9
+ * `EffectSignal` is designed for running logic that interacts with the "outside world"—such as
10
+ * logging, network requests, or DOM manipulation—whenever its dependencies are updated.
11
+ * It encapsulates the subscription and cleanup logic, providing a robust and memory-safe
12
+ * way to handle reactive side-effects.
13
+ *
14
+ * A key feature is its lifecycle management: an `EffectSignal` **must** be destroyed when no longer
15
+ * needed to prevent memory leaks and stop the effect from running unnecessarily.
16
+ *
17
+ * @implements {IEffectSignal}
18
+ *
19
+ * @example
20
+ * // --- Create dependency signals ---
21
+ * const counter = new StateSignal({ initialValue: 0, name: 'counter' });
22
+ * const user = new StateSignal({ initialValue: 'guest', name: 'user' });
23
+ *
24
+ * // --- Create an effect ---
25
+ * const analyticsEffect = new EffectSignal({
26
+ * name: 'analytics-effect',
27
+ * deps: [counter, user],
28
+ * run: () => {
29
+ * console.log(`Analytics: User '${user.get()}' clicked ${counter.get()} times.`);
30
+ * },
31
+ * runImmediately: true, // Optional: run once on creation
32
+ * });
33
+ * // Immediately logs: "Analytics: User 'guest' clicked 0 times."
34
+ *
35
+ * // --- Trigger the effect by updating a dependency ---
36
+ * counter.set(1);
37
+ * // After a macrotask, logs: "Analytics: User 'guest' clicked 1 times."
38
+ *
39
+ * // --- IMPORTANT: Clean up when the effect is no longer needed ---
40
+ * analyticsEffect.destroy();
41
+ *
42
+ * // Further updates will not trigger the effect.
43
+ * counter.set(2); // Nothing is logged.
44
+ */
45
+ export class EffectSignal implements IEffectSignal {
46
+ /**
47
+ * The unique identifier for this signal instance.
48
+ */
49
+ public readonly name: string;
50
+
51
+ /**
52
+ * The logger instance for this signal.
53
+ * @protected
54
+ */
55
+ protected readonly logger_: AlwatrLogger;
56
+
57
+ /**
58
+ * A list of subscriptions to dependency signals.
59
+ * @private
60
+ */
61
+ private readonly dependencySubscriptions__: SubscribeResult[] = [];
62
+
63
+ /**
64
+ * A flag to prevent concurrent executions of the effect.
65
+ * @private
66
+ */
67
+ private isRunning__ = false;
68
+
69
+ /**
70
+ * A flag indicating whether the effect has been destroyed.
71
+ * @private
72
+ */
73
+ private isDestroyed__ = false;
74
+
75
+ /**
76
+ * Indicates whether the effect signal has been destroyed.
77
+ * A destroyed signal will no longer execute its effect and cannot be reused.
78
+ *
79
+ * @returns `true` if the signal is destroyed, `false` otherwise.
80
+ */
81
+ public get isDestroyed(): boolean {
82
+ return this.isDestroyed__;
83
+ }
84
+
85
+ constructor(protected config_: EffectSignalConfig) {
86
+ this.name = config_.name ?? `[${config_.deps.map((dep) => dep.name).join(', ')}]`;
87
+ this.logger_ = createLogger(`effect-signal:${this.name}`);
88
+ this.scheduleExecution_ = this.scheduleExecution_.bind(this);
89
+
90
+ this.logger_.logMethod?.('constructor');
91
+
92
+ // Subscribe to all dependencies. We don't need the previous value,
93
+ // as the `runImmediately` option controls the initial execution.
94
+ 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 }));
97
+ }
98
+
99
+ // Run the effect immediately if requested.
100
+ if (config_.runImmediately === true) {
101
+ this.logger_.logStep?.('constructor', 'scheduling_initial_execution');
102
+ // We don't need to await this, let it run in the background.
103
+ void this.scheduleExecution_();
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Schedules the execution of the effect's `run` function.
109
+ *
110
+ * This method batches updates using a macrotask (`delay.nextMacrotask`) to ensure the
111
+ * `run` function executes only once per event loop tick, even if multiple
112
+ * dependencies change simultaneously.
113
+ * @protected
114
+ */
115
+ protected async scheduleExecution_(): Promise<void> {
116
+ this.logger_.logMethod?.('scheduleExecution_');
117
+
118
+ if (this.isDestroyed__) {
119
+ this.logger_.incident?.('scheduleExecution_', 'schedule_execution_on_destroyed_signal');
120
+ return;
121
+ }
122
+ if (this.isRunning__) {
123
+ // If an execution is already scheduled, do nothing.
124
+ this.logger_.logStep?.('scheduleExecution_', 'skipped_because_already_running');
125
+ return;
126
+ }
127
+
128
+ this.isRunning__ = true;
129
+
130
+ try {
131
+ // Wait for the next macrotask to batch simultaneous updates.
132
+ await delay.nextMacrotask();
133
+ if (this.isDestroyed__) {
134
+ this.logger_.incident?.('scheduleExecution_', 'destroyed_during_delay');
135
+ this.isRunning__ = false;
136
+ return;
137
+ }
138
+
139
+ this.logger_.logStep?.('scheduleExecution_', 'executing_effect');
140
+ await this.config_.run();
141
+ }
142
+ catch (err) {
143
+ this.logger_.error('scheduleExecution_', 'effect_failed', err);
144
+ }
145
+
146
+ // Reset the flag after the current execution is complete.
147
+ this.isRunning__ = false;
148
+ }
149
+
150
+ /**
151
+ * Permanently disposes of the effect signal.
152
+ *
153
+ * This is a critical cleanup step. It unsubscribes from all dependency signals,
154
+ * stopping any future executions of the effect and allowing it to be garbage collected.
155
+ * Failure to call `destroy()` will result in memory leaks and potentially unwanted side effects.
156
+ */
157
+ public destroy(): void {
158
+ this.logger_.logMethod?.('destroy');
159
+
160
+ if (this.isDestroyed__) {
161
+ this.logger_.incident?.('destroy', 'already_destroyed');
162
+ return;
163
+ }
164
+
165
+ this.isDestroyed__ = true;
166
+
167
+ // Unsubscribe from all upstream dependencies.
168
+ for (const subscription of this.dependencySubscriptions__) {
169
+ subscription.unsubscribe();
170
+ }
171
+ this.dependencySubscriptions__.length = 0; // Clear the array of subscriptions.
172
+
173
+ this.config_.onDestroy?.(); // Call the optional onDestroy callback.
174
+ this.config_ = null as unknown as EffectSignalConfig; // Release config closure.
175
+ }
176
+ }
@@ -0,0 +1,61 @@
1
+ import { delay } from '@alwatr/delay';
2
+ import { createLogger, type AlwatrLogger } from '@alwatr/logger';
3
+
4
+ import { SignalBase } from './signal-base.js';
5
+
6
+ import type { SignalConfig } from '../type.js';
7
+
8
+ /**
9
+ * A stateless signal for dispatching transient events.
10
+ *
11
+ * `EventSignal` is ideal for broadcasting events that do not have a persistent state.
12
+ * Unlike `StateSignal`, it does not hold a value. Listeners are only notified of new
13
+ * events as they are dispatched. This makes it suitable for modeling user interactions,
14
+ * system notifications, or any one-off message.
15
+ *
16
+ * @template T The type of the payload for the events. Defaults to `void` for events without a payload.
17
+ *
18
+ * @example
19
+ * // Create a signal for user click events.
20
+ * const onUserClick = new EventSignal<{ x: number, y: number }>({ name: 'on-user-click' });
21
+ *
22
+ * // Subscribe to the event.
23
+ * onUserClick.subscribe(clickPosition => {
24
+ * console.log(`User clicked at: ${clickPosition.x}, ${clickPosition.y}`);
25
+ * });
26
+ *
27
+ * // Dispatch an event.
28
+ * onUserClick.dispatch({ x: 100, y: 250 }); // Notifies the listener.
29
+ *
30
+ * // --- Example with no payload ---
31
+ * const onAppReady = new EventSignal({ name: 'on-app-ready' });
32
+ * onAppReady.subscribe(() => console.log('Application is ready!'));
33
+ * onAppReady.dispatch(); // Notifies the listener.
34
+ */
35
+ export class EventSignal<T = void> extends SignalBase<T> {
36
+ /**
37
+ * The logger instance for this signal.
38
+ * @protected
39
+ */
40
+ protected logger_: AlwatrLogger;
41
+
42
+ constructor(config: SignalConfig) {
43
+ super(config);
44
+ this.logger_ = createLogger(`event-signal:${this.name}`);
45
+ this.logger_.logMethod?.('constructor');
46
+ }
47
+
48
+ /**
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.
52
+ *
53
+ * @param payload The data to send with the event.
54
+ */
55
+ public dispatch(payload: T): void {
56
+ this.logger_.logMethodArgs?.('dispatch', { payload });
57
+ this.checkDestroyed_();
58
+ // Dispatch as a microtask to ensure consistent, non-blocking behavior.
59
+ delay.nextMicrotask().then(() => this.notify_(payload));
60
+ }
61
+ }
@@ -0,0 +1,98 @@
1
+ import {createDebouncer} from '@alwatr/debounce';
2
+ import {createLocalStorageProvider} from '@alwatr/local-storage';
3
+
4
+ import {StateSignal} from './state-signal.js';
5
+
6
+ import type {PersistentStateSignalConfig} from '../type.js';
7
+ import type {LocalStorageProvider} from '@alwatr/local-storage';
8
+
9
+ /**
10
+ * A stateful signal that persists its value in the browser's localStorage.
11
+ *
12
+ * It extends the functionality of a standard `StateSignal` by automatically reading
13
+ * its initial value from localStorage and writing back any subsequent changes.
14
+ *
15
+ * @template T The type of the state it holds.
16
+ */
17
+ export class PersistentStateSignal<T extends JsonValue> extends StateSignal<T> {
18
+ /**
19
+ * The underlying storage provider instance.
20
+ * @private
21
+ */
22
+ private readonly storageProvider__: LocalStorageProvider<T>;
23
+
24
+ /**
25
+ * Debouncer to limit how often we write to localStorage.
26
+ * @private
27
+ */
28
+ private readonly storageDebouncer__;
29
+
30
+ /**
31
+ * The subscription to the signal's own changes to sync with storage.
32
+ * We subscribe to our own signal. When the value is set from anywhere,
33
+ * this listener will trigger and write it to localStorage.
34
+ * @private
35
+ */
36
+ private readonly storageSyncSubscription__;
37
+
38
+ constructor(config: PersistentStateSignalConfig<T>) {
39
+ const {name, storageKey = name, saveDebounceDelay = 500, initialValue, onDestroy, schemaVersion} = config;
40
+
41
+ const storageProvider = createLocalStorageProvider<T>({
42
+ name: storageKey,
43
+ schemaVersion,
44
+ });
45
+
46
+ super({
47
+ name,
48
+ initialValue: storageProvider.read() ?? initialValue,
49
+ onDestroy,
50
+ });
51
+
52
+ this.logger_.logMethodArgs?.('constructor', config);
53
+
54
+ this.storageProvider__ = storageProvider;
55
+
56
+ this.storageDebouncer__ = createDebouncer({
57
+ delay: saveDebounceDelay,
58
+ leading: false,
59
+ trailing: true,
60
+ thisContext: this,
61
+ func: this.syncStorage__,
62
+ });
63
+
64
+ this.storageSyncSubscription__ = this.subscribe(this.storageDebouncer__.trigger, {receivePrevious: false});
65
+ }
66
+
67
+ /**
68
+ * Syncs the new value to storage.
69
+ * @param newValue The new value to sync to storage.
70
+ */
71
+ private syncStorage__(newValue: T): void {
72
+ this.logger_.logMethodArgs?.('syncStorage__', newValue);
73
+ this.storageProvider__.write(newValue);
74
+ }
75
+
76
+ /**
77
+ * Removes the value from localStorage.
78
+ * This provides a clean way to clear persisted data.
79
+ */
80
+ public remove(): void {
81
+ this.checkDestroyed_();
82
+ this.logger_.logMethod?.('remove');
83
+ // Remove from storage.
84
+ this.storageProvider__.remove();
85
+ }
86
+
87
+ /**
88
+ * Overrides the destroy method to also clean up the storage sync subscription.
89
+ */
90
+ public override destroy(): void {
91
+ this.logger_.logMethod?.('destroy');
92
+ // Flush any pending storage writes before destroying.
93
+ this.storageDebouncer__.flush();
94
+ // Unsubscribe from the sync listener to prevent memory leaks.
95
+ this.storageSyncSubscription__.unsubscribe();
96
+ super.destroy();
97
+ }
98
+ }
@@ -0,0 +1,145 @@
1
+ import { createDebouncer } from '@alwatr/debounce';
2
+ import { createSessionStorageProvider } from '@alwatr/session-storage';
3
+
4
+ import { StateSignal } from './state-signal.js';
5
+
6
+ import type { SessionStateSignalConfig } from '../type.js';
7
+ import type { SessionStorageProvider } from '@alwatr/session-storage';
8
+
9
+ /**
10
+ * A stateful signal that persists its value in the browser's `sessionStorage`.
11
+ *
12
+ * It extends `StateSignal` by automatically reading its initial value from `sessionStorage`
13
+ * and writing back any subsequent changes. Unlike `PersistentStateSignal`, the data is cleared
14
+ * automatically when the browser tab or window is closed — it does not survive full page reloads
15
+ * in a new session.
16
+ *
17
+ * This is ideal for transient UI state that should survive soft navigations and refreshes
18
+ * within the same browser tab (e.g., wizard steps, unsaved form drafts, scroll position).
19
+ *
20
+ * @template T The type of the state it holds. Must be JSON-serializable.
21
+ *
22
+ * @example
23
+ * ```typescript
24
+ * import {SessionStateSignal} from '@alwatr/signal';
25
+ *
26
+ * interface WizardState {
27
+ * step: number;
28
+ * answers: Record<string, string>;
29
+ * }
30
+ *
31
+ * const wizardSignal = new SessionStateSignal<WizardState>({
32
+ * name: 'checkout-wizard',
33
+ * initialValue: { step: 1, answers: {} },
34
+ * });
35
+ *
36
+ * // On first load: reads from sessionStorage (or uses initialValue if not found).
37
+ * console.log(wizardSignal.get()); // { step: 1, answers: {} }
38
+ *
39
+ * // Update state — written to sessionStorage automatically (debounced).
40
+ * wizardSignal.set({ step: 2, answers: { q1: 'yes' } });
41
+ *
42
+ * // After a soft page reload, the state is restored from sessionStorage.
43
+ *
44
+ * // Clear the persisted session data without destroying the signal.
45
+ * wizardSignal.remove();
46
+ *
47
+ * // Clean up when the component/page is unmounted.
48
+ * wizardSignal.destroy();
49
+ * ```
50
+ */
51
+ export class SessionStateSignal<T extends JsonValue> extends StateSignal<T> {
52
+ /**
53
+ * The underlying session storage provider instance.
54
+ * @private
55
+ */
56
+ private readonly storageProvider__: SessionStorageProvider<T>;
57
+
58
+ /**
59
+ * Debouncer to limit how often we write to sessionStorage.
60
+ * @private
61
+ */
62
+ private readonly storageDebouncer__;
63
+
64
+ /**
65
+ * Subscription to the signal's own changes for sessionStorage sync.
66
+ * @private
67
+ */
68
+ private readonly storageSyncSubscription__;
69
+
70
+ constructor(config: SessionStateSignalConfig<T>) {
71
+ const { name, storageKey = name, saveDebounceDelay = 500, initialValue, onDestroy } = config;
72
+
73
+ const storageProvider = createSessionStorageProvider<T>({ name: storageKey });
74
+
75
+ super({
76
+ name,
77
+ initialValue: storageProvider.read() ?? initialValue,
78
+ onDestroy,
79
+ });
80
+
81
+ this.logger_.logMethodArgs?.('constructor', config);
82
+
83
+ this.storageProvider__ = storageProvider;
84
+
85
+ this.storageDebouncer__ = createDebouncer({
86
+ delay: saveDebounceDelay,
87
+ leading: false,
88
+ trailing: true,
89
+ thisContext: this,
90
+ func: this.syncStorage__,
91
+ });
92
+
93
+ this.storageSyncSubscription__ = this.subscribe(this.storageDebouncer__.trigger, { receivePrevious: false });
94
+ }
95
+
96
+ /**
97
+ * Syncs the new value to sessionStorage.
98
+ * Called automatically by the debouncer after each state change.
99
+ *
100
+ * @param newValue The new value to sync.
101
+ */
102
+ private syncStorage__(newValue: T): void {
103
+ this.logger_.logMethodArgs?.('syncStorage__', newValue);
104
+ this.storageProvider__.write(newValue);
105
+ }
106
+
107
+ /**
108
+ * Removes the stored value from sessionStorage without destroying the signal.
109
+ *
110
+ * After calling this, the signal continues to hold its current in-memory value;
111
+ * only the sessionStorage entry is cleared.
112
+ *
113
+ * @example
114
+ * ```typescript
115
+ * // User logs out — clear transient session data.
116
+ * wizardSignal.remove();
117
+ * ```
118
+ */
119
+ public remove(): void {
120
+ this.checkDestroyed_();
121
+ this.logger_.logMethod?.('remove');
122
+ this.storageProvider__.remove();
123
+ }
124
+
125
+ /**
126
+ * Destroys the signal, flushing pending writes and cleaning up all resources.
127
+ *
128
+ * Always call this when the signal is no longer needed (e.g., on component unmount)
129
+ * to prevent memory leaks.
130
+ *
131
+ * @example
132
+ * ```typescript
133
+ * // In a component teardown:
134
+ * wizardSignal.destroy();
135
+ * ```
136
+ */
137
+ public override destroy(): void {
138
+ this.logger_.logMethod?.('destroy');
139
+ // Flush any pending debounced writes before destroying.
140
+ this.storageDebouncer__.flush();
141
+ // Unsubscribe the storage sync listener to prevent memory leaks.
142
+ this.storageSyncSubscription__.unsubscribe();
143
+ super.destroy();
144
+ }
145
+ }