@blac/core 2.0.0-rc-8 → 2.0.0-rc-11

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/src/BlocBase.ts CHANGED
@@ -2,7 +2,31 @@ import { BlacObservable } from './BlacObserver';
2
2
  import { generateUUID } from './utils/uuid';
3
3
 
4
4
  export type BlocInstanceId = string | number | undefined;
5
- type DependencySelector<S> = (currentState: S, previousState: S | undefined, instance: any) => unknown[];
5
+ type DependencySelector<S> = (
6
+ currentState: S,
7
+ previousState: S | undefined,
8
+ instance: any,
9
+ ) => unknown[];
10
+
11
+ /**
12
+ * Enum representing the lifecycle states of a Bloc instance
13
+ * Used for atomic state transitions to prevent race conditions
14
+ */
15
+ export enum BlocLifecycleState {
16
+ ACTIVE = 'active',
17
+ DISPOSAL_REQUESTED = 'disposal_requested',
18
+ DISPOSING = 'disposing',
19
+ DISPOSED = 'disposed',
20
+ }
21
+
22
+ /**
23
+ * Result of an atomic state transition operation
24
+ */
25
+ export interface StateTransitionResult {
26
+ success: boolean;
27
+ currentState: BlocLifecycleState;
28
+ previousState: BlocLifecycleState;
29
+ }
6
30
 
7
31
  // Define an interface for the static properties expected on a Bloc/Cubit constructor
8
32
  interface BlocStaticProperties {
@@ -13,15 +37,12 @@ interface BlocStaticProperties {
13
37
  /**
14
38
  * Base class for both Blocs and Cubits that provides core state management functionality.
15
39
  * Handles state transitions, observer notifications, lifecycle management, and addon integration.
16
- *
40
+ *
17
41
  * @abstract This class should be extended, not instantiated directly
18
42
  * @template S The type of state managed by this Bloc
19
43
  * @template P The type of props that can be passed during instance creation (optional)
20
44
  */
21
- export abstract class BlocBase<
22
- S,
23
- P = unknown
24
- > {
45
+ export abstract class BlocBase<S, P = unknown> {
25
46
  public uid = generateUUID();
26
47
  /**
27
48
  * When true, every consumer will receive its own unique instance of this Bloc.
@@ -32,7 +53,7 @@ export abstract class BlocBase<
32
53
  get isIsolated() {
33
54
  return this._isolated;
34
55
  }
35
-
56
+
36
57
  /**
37
58
  * When true, the Bloc instance persists even when there are no active consumers.
38
59
  * Useful for maintaining state between component unmount/remount cycles.
@@ -42,43 +63,43 @@ export abstract class BlocBase<
42
63
  get isKeepAlive() {
43
64
  return this._keepAlive;
44
65
  }
45
-
66
+
46
67
  /**
47
68
  * Defines how dependencies are selected from the state for efficient updates.
48
69
  * When provided, observers will only be notified when selected dependencies change.
49
70
  */
50
71
  defaultDependencySelector: DependencySelector<S> | undefined;
51
-
72
+
52
73
  /**
53
74
  * @internal
54
75
  * Indicates if this specific Bloc instance is isolated from others of the same type.
55
76
  */
56
77
  public _isolated = false;
57
-
78
+
58
79
  /**
59
80
  * @internal
60
81
  * Observable responsible for managing state listeners and notifying consumers.
61
82
  */
62
83
  public _observer: BlacObservable<S>;
63
-
84
+
64
85
  /**
65
86
  * The unique identifier for this Bloc instance.
66
87
  * Defaults to the class name, but can be customized.
67
88
  */
68
89
  public _id: BlocInstanceId;
69
-
90
+
70
91
  /**
71
92
  * @internal
72
93
  * Reference string used internally for tracking and debugging.
73
94
  */
74
95
  public _instanceRef?: string;
75
-
96
+
76
97
  /**
77
98
  * @internal
78
99
  * Indicates if this specific Bloc instance should be kept alive when no consumers are present.
79
100
  */
80
101
  public _keepAlive = false;
81
-
102
+
82
103
  /**
83
104
  * @readonly
84
105
  * Timestamp when this Bloc instance was created, useful for debugging and performance tracking.
@@ -87,22 +108,28 @@ export abstract class BlocBase<
87
108
 
88
109
  /**
89
110
  * @internal
90
- * Disposal state to prevent race conditions
111
+ * Atomic disposal state to prevent race conditions
112
+ */
113
+ private _disposalState: BlocLifecycleState = BlocLifecycleState.ACTIVE;
114
+
115
+ /**
116
+ * @internal
117
+ * Timestamp when disposal was requested (for React Strict Mode grace period)
91
118
  */
92
- private _disposalState: 'active' | 'disposing' | 'disposed' = 'active';
119
+ private _disposalRequestTime: number = 0;
93
120
 
94
121
  /**
95
122
  * @internal
96
123
  * The current state of the Bloc.
97
124
  */
98
125
  public _state: S;
99
-
126
+
100
127
  /**
101
128
  * @internal
102
129
  * The previous state of the Bloc, maintained for comparison and history.
103
130
  */
104
131
  public _oldState: S | undefined;
105
-
132
+
106
133
  /**
107
134
  * Props passed during Bloc instance creation.
108
135
  * Can be used to configure or parameterize the Bloc's behavior.
@@ -119,7 +146,11 @@ export abstract class BlocBase<
119
146
  * @internal
120
147
  * Pending batched updates
121
148
  */
122
- private _pendingUpdates: Array<{newState: S, oldState: S, action?: unknown}> = [];
149
+ private _pendingUpdates: Array<{
150
+ newState: S;
151
+ oldState: S;
152
+ action?: unknown;
153
+ }> = [];
123
154
 
124
155
  /**
125
156
  * @internal
@@ -134,21 +165,25 @@ export abstract class BlocBase<
134
165
  */
135
166
  _validateConsumers = (): void => {
136
167
  const deadConsumers: string[] = [];
137
-
168
+
138
169
  for (const [consumerId, weakRef] of this._consumerRefs) {
139
170
  if (weakRef.deref() === undefined) {
140
171
  deadConsumers.push(consumerId);
141
172
  }
142
173
  }
143
-
174
+
144
175
  // Clean up dead consumers
145
176
  for (const consumerId of deadConsumers) {
146
177
  this._consumers.delete(consumerId);
147
178
  this._consumerRefs.delete(consumerId);
148
179
  }
149
-
180
+
150
181
  // Schedule disposal if no live consumers remain
151
- if (this._consumers.size === 0 && !this._keepAlive && this._disposalState === 'active') {
182
+ if (
183
+ this._consumers.size === 0 &&
184
+ !this._keepAlive &&
185
+ this._disposalState === BlocLifecycleState.ACTIVE
186
+ ) {
152
187
  this._scheduleDisposal();
153
188
  }
154
189
  };
@@ -156,7 +191,7 @@ export abstract class BlocBase<
156
191
  /**
157
192
  * Creates a new BlocBase instance with the given initial state.
158
193
  * Sets up the observer, registers with the Blac manager, and initializes addons.
159
- *
194
+ *
160
195
  * @param initialState The initial state value for this Bloc
161
196
  */
162
197
  constructor(initialState: S) {
@@ -165,21 +200,40 @@ export abstract class BlocBase<
165
200
  this._id = this.constructor.name;
166
201
 
167
202
  // Access static properties safely with proper type checking
168
- const Constructor = this.constructor as typeof BlocBase & BlocStaticProperties;
169
-
203
+ const Constructor = this.constructor as typeof BlocBase &
204
+ BlocStaticProperties;
205
+
170
206
  // Validate that the static properties exist and are boolean
171
- this._keepAlive = typeof Constructor.keepAlive === 'boolean' ? Constructor.keepAlive : false;
172
- this._isolated = typeof Constructor.isolated === 'boolean' ? Constructor.isolated : false;
207
+ this._keepAlive =
208
+ typeof Constructor.keepAlive === 'boolean'
209
+ ? Constructor.keepAlive
210
+ : false;
211
+ this._isolated =
212
+ typeof Constructor.isolated === 'boolean' ? Constructor.isolated : false;
173
213
  }
174
214
 
175
215
  /**
176
216
  * Returns the current state of the Bloc.
177
217
  * Use this getter to access the state in a read-only manner.
218
+ * Returns the state even during transitional lifecycle states for React compatibility.
178
219
  */
179
220
  get state(): S {
221
+ // Allow state access during all states except DISPOSED for React compatibility
222
+ if (this._disposalState === BlocLifecycleState.DISPOSED) {
223
+ // Return the last known state for disposed blocs to prevent crashes
224
+ return this._state;
225
+ }
180
226
  return this._state;
181
227
  }
182
228
 
229
+ /**
230
+ * Returns whether this Bloc instance has been disposed.
231
+ * @returns true if the bloc is in DISPOSED state
232
+ */
233
+ get isDisposed(): boolean {
234
+ return this._disposalState === BlocLifecycleState.DISPOSED;
235
+ }
236
+
183
237
  /**
184
238
  * @internal
185
239
  * Returns the name of the Bloc class for identification and debugging.
@@ -192,7 +246,7 @@ export abstract class BlocBase<
192
246
  * @internal
193
247
  * Updates the Bloc instance's ID to a new value.
194
248
  * Only updates if the new ID is defined and different from the current one.
195
- *
249
+ *
196
250
  * @param id The new ID to assign to this Bloc instance
197
251
  */
198
252
  _updateId = (id?: BlocInstanceId) => {
@@ -201,33 +255,95 @@ export abstract class BlocBase<
201
255
  this._id = id;
202
256
  };
203
257
 
258
+ /**
259
+ * @internal
260
+ * Performs atomic state transition using compare-and-swap semantics
261
+ * @param expectedState The expected current state
262
+ * @param newState The desired new state
263
+ * @returns Result indicating success/failure and state information
264
+ */
265
+ _atomicStateTransition(
266
+ expectedState: BlocLifecycleState,
267
+ newState: BlocLifecycleState,
268
+ ): StateTransitionResult {
269
+ if (this._disposalState === expectedState) {
270
+ const previousState = this._disposalState;
271
+ this._disposalState = newState;
272
+
273
+ // Log state transition for debugging
274
+ if ((globalThis as any).Blac?.enableLog) {
275
+ (globalThis as any).Blac?.log(
276
+ `[${this._name}:${this._id}] State transition: ${previousState} -> ${newState} (SUCCESS)`,
277
+ );
278
+ }
279
+
280
+ return {
281
+ success: true,
282
+ currentState: newState,
283
+ previousState,
284
+ };
285
+ }
286
+
287
+ // Log failed transition attempt
288
+ if ((globalThis as any).Blac?.enableLog) {
289
+ (globalThis as any).Blac?.log(
290
+ `[${this._name}:${this._id}] State transition failed: expected ${expectedState}, current ${this._disposalState}`,
291
+ );
292
+ }
293
+
294
+ return {
295
+ success: false,
296
+ currentState: this._disposalState,
297
+ previousState: expectedState,
298
+ };
299
+ }
300
+
204
301
  /**
205
302
  * @internal
206
303
  * Cleans up resources and removes this Bloc from the system.
207
304
  * Notifies the Blac manager and clears all observers.
208
305
  */
209
- _dispose() {
210
- // Prevent re-entrant disposal using atomic state change
211
- if (this._disposalState !== 'active') {
212
- return;
306
+ _dispose(): boolean {
307
+ // Step 1: Attempt atomic transition to DISPOSING state from either ACTIVE or DISPOSAL_REQUESTED
308
+ let transitionResult = this._atomicStateTransition(
309
+ BlocLifecycleState.ACTIVE,
310
+ BlocLifecycleState.DISPOSING,
311
+ );
312
+
313
+ // If that failed, try from DISPOSAL_REQUESTED state
314
+ if (!transitionResult.success) {
315
+ transitionResult = this._atomicStateTransition(
316
+ BlocLifecycleState.DISPOSAL_REQUESTED,
317
+ BlocLifecycleState.DISPOSING,
318
+ );
319
+ }
320
+
321
+ if (!transitionResult.success) {
322
+ // Already disposing or disposed - idempotent operation
323
+ return false;
324
+ }
325
+
326
+ try {
327
+ // Step 2: Perform cleanup operations
328
+ this._consumers.clear();
329
+ this._consumerRefs.clear();
330
+ this._observer.clear();
331
+
332
+ // Call user-defined disposal hook
333
+ this.onDispose?.();
334
+
335
+ // Step 3: Final state transition to DISPOSED
336
+ const finalResult = this._atomicStateTransition(
337
+ BlocLifecycleState.DISPOSING,
338
+ BlocLifecycleState.DISPOSED,
339
+ );
340
+
341
+ return finalResult.success;
342
+ } catch (error) {
343
+ // Recovery: Reset state on cleanup failure
344
+ this._disposalState = BlocLifecycleState.ACTIVE;
345
+ throw error;
213
346
  }
214
- this._disposalState = 'disposing';
215
-
216
- // Clear all consumers and their references
217
- this._consumers.clear();
218
- this._consumerRefs.clear();
219
-
220
- // Clear observer subscriptions
221
- this._observer.clear();
222
-
223
- // Call user-defined disposal hook
224
- this.onDispose?.();
225
-
226
- // Mark as fully disposed
227
- this._disposalState = 'disposed';
228
-
229
- // The Blac manager will handle removal from registry
230
- // This method is called by Blac.disposeBloc, so we don't need to call it again
231
347
  }
232
348
 
233
349
  /**
@@ -246,51 +362,65 @@ export abstract class BlocBase<
246
362
  * @internal
247
363
  * Registers a new consumer to this Bloc instance.
248
364
  * Notifies the Blac manager that a consumer has been added.
249
- *
365
+ *
250
366
  * @param consumerId The unique ID of the consumer being added
251
367
  * @param consumerRef Optional reference to the consumer object for cleanup validation
252
368
  */
253
- _addConsumer = (consumerId: string, consumerRef?: object) => {
254
- // Prevent adding consumers to disposed blocs
255
- if (this._disposalState !== 'active') {
256
- return;
369
+ _addConsumer = (consumerId: string, consumerRef?: object): boolean => {
370
+ // Atomic state validation - only allow consumer addition in ACTIVE state
371
+ if (this._disposalState !== BlocLifecycleState.ACTIVE) {
372
+ return false; // Clear failure indication
257
373
  }
258
-
259
- if (this._consumers.has(consumerId)) return;
374
+
375
+ // Prevent duplicate consumers
376
+ if (this._consumers.has(consumerId)) return true;
377
+
378
+ // Safe consumer addition
260
379
  this._consumers.add(consumerId);
261
-
380
+
262
381
  // Store WeakRef for proper memory management
263
382
  if (consumerRef) {
264
383
  this._consumerRefs.set(consumerId, new WeakRef(consumerRef));
265
384
  }
266
-
385
+
267
386
  // @ts-ignore - Blac is available globally
268
- (globalThis as any).Blac?.log(`[${this._name}:${this._id}] Consumer added. Total consumers: ${this._consumers.size}`);
269
-
387
+ (globalThis as any).Blac?.log(
388
+ `[${this._name}:${this._id}] Consumer added. Total consumers: ${this._consumers.size}`,
389
+ );
390
+
270
391
  // this._blac.dispatchEvent(BlacLifecycleEvent.BLOC_CONSUMER_ADDED, this, { consumerId });
392
+ return true;
271
393
  };
272
394
 
273
395
  /**
274
396
  * @internal
275
397
  * Unregisters a consumer from this Bloc instance.
276
398
  * Notifies the Blac manager that a consumer has been removed.
277
- *
399
+ *
278
400
  * @param consumerId The unique ID of the consumer being removed
279
401
  */
280
402
  _removeConsumer = (consumerId: string) => {
281
403
  if (!this._consumers.has(consumerId)) return;
282
-
404
+
283
405
  this._consumers.delete(consumerId);
284
406
  this._consumerRefs.delete(consumerId);
285
407
  // this._blac.dispatchEvent(BlacLifecycleEvent.BLOC_CONSUMER_REMOVED, this, { consumerId });
286
-
408
+
287
409
  // @ts-ignore - Blac is available globally
288
- (globalThis as any).Blac?.log(`[${this._name}:${this._id}] Consumer removed. Remaining consumers: ${this._consumers.size}, keepAlive: ${this._keepAlive}`);
289
-
410
+ (globalThis as any).Blac?.log(
411
+ `[${this._name}:${this._id}] Consumer removed. Remaining consumers: ${this._consumers.size}, keepAlive: ${this._keepAlive}`,
412
+ );
413
+
290
414
  // If no consumers remain and not keep-alive, schedule disposal
291
- if (this._consumers.size === 0 && !this._keepAlive && this._disposalState === 'active') {
415
+ if (
416
+ this._consumers.size === 0 &&
417
+ !this._keepAlive &&
418
+ this._disposalState === BlocLifecycleState.ACTIVE
419
+ ) {
292
420
  // @ts-ignore - Blac is available globally
293
- (globalThis as any).Blac?.log(`[${this._name}:${this._id}] No consumers left and not keep-alive. Scheduling disposal.`);
421
+ (globalThis as any).Blac?.log(
422
+ `[${this._name}:${this._id}] No consumers left and not keep-alive. Scheduling disposal.`,
423
+ );
294
424
  this._scheduleDisposal();
295
425
  }
296
426
  };
@@ -312,21 +442,60 @@ export abstract class BlocBase<
312
442
  /**
313
443
  * @internal
314
444
  * Schedules disposal of this bloc instance if it has no consumers
315
- */
316
- private _scheduleDisposal() {
317
- // Prevent multiple disposal attempts
318
- if (this._disposalState !== 'active') {
445
+ * Uses atomic state transitions to prevent race conditions
446
+ */
447
+ private _scheduleDisposal(): void {
448
+ // Step 1: Atomic transition to DISPOSAL_REQUESTED
449
+ const requestResult = this._atomicStateTransition(
450
+ BlocLifecycleState.ACTIVE,
451
+ BlocLifecycleState.DISPOSAL_REQUESTED,
452
+ );
453
+
454
+ if (!requestResult.success) {
455
+ // Already requested, disposing, or disposed
456
+ return;
457
+ }
458
+
459
+ // Step 2: Verify disposal conditions under atomic protection
460
+ const shouldDispose = this._consumers.size === 0 && !this._keepAlive;
461
+
462
+ if (!shouldDispose) {
463
+ // Conditions no longer met, revert to active
464
+ this._atomicStateTransition(
465
+ BlocLifecycleState.DISPOSAL_REQUESTED,
466
+ BlocLifecycleState.ACTIVE,
467
+ );
319
468
  return;
320
469
  }
321
-
322
- // Double-check conditions before disposal
323
- if (this._consumers.size === 0 && !this._keepAlive) {
324
- if (this._disposalHandler) {
325
- this._disposalHandler(this as any);
470
+
471
+ // Record disposal request time for tracking
472
+ this._disposalRequestTime = Date.now();
473
+
474
+ // Step 3: Defer disposal until after current execution completes
475
+ // This allows React Strict Mode's immediate remount to cancel disposal
476
+ queueMicrotask(() => {
477
+ // Re-verify disposal conditions - React Strict Mode remount may have cancelled this
478
+ const stillShouldDispose =
479
+ this._consumers.size === 0 &&
480
+ !this._keepAlive &&
481
+ this._observer.size === 0 &&
482
+ (this as any)._disposalState === BlocLifecycleState.DISPOSAL_REQUESTED;
483
+
484
+ if (stillShouldDispose) {
485
+ // No cancellation occurred, proceed with disposal
486
+ if (this._disposalHandler) {
487
+ this._disposalHandler(this as any);
488
+ } else {
489
+ this._dispose();
490
+ }
326
491
  } else {
327
- this._dispose();
492
+ // Disposal was cancelled (React Strict Mode remount), revert to active
493
+ this._atomicStateTransition(
494
+ BlocLifecycleState.DISPOSAL_REQUESTED,
495
+ BlocLifecycleState.ACTIVE,
496
+ );
328
497
  }
329
- }
498
+ });
330
499
  }
331
500
 
332
501
  lastUpdate = Date.now();
@@ -340,7 +509,7 @@ export abstract class BlocBase<
340
509
  /**
341
510
  * @internal
342
511
  * Updates the state and notifies all observers of the change.
343
- *
512
+ *
344
513
  * @param newState The new state to be set
345
514
  * @param oldState The previous state for comparison
346
515
  * @param action Optional metadata about what caused the state change
@@ -348,20 +517,22 @@ export abstract class BlocBase<
348
517
  _pushState = (newState: S, oldState: S, action?: unknown): void => {
349
518
  // Validate newState
350
519
  if (newState === undefined) {
351
- console.warn('BlocBase._pushState: newState is undefined', this);
352
520
  return;
353
521
  }
354
522
 
355
523
  // Validate action type if provided
356
- if (action !== undefined && typeof action !== 'object' && typeof action !== 'function') {
357
- console.warn('BlocBase._pushState: Invalid action type', this, action);
524
+ if (
525
+ action !== undefined &&
526
+ typeof action !== 'object' &&
527
+ typeof action !== 'function'
528
+ ) {
358
529
  return;
359
530
  }
360
531
 
361
532
  if (this._batchingEnabled) {
362
533
  // When batching, just accumulate the updates
363
534
  this._pendingUpdates.push({ newState, oldState, action });
364
-
535
+
365
536
  // Update internal state for consistency
366
537
  this._oldState = oldState;
367
538
  this._state = newState;
@@ -371,7 +542,7 @@ export abstract class BlocBase<
371
542
  // Normal state update flow
372
543
  this._oldState = oldState;
373
544
  this._state = newState;
374
-
545
+
375
546
  // Notify observers of the state change
376
547
  this._observer.notify(newState, oldState, action);
377
548
  this.lastUpdate = Date.now();
@@ -387,22 +558,27 @@ export abstract class BlocBase<
387
558
  // If already batching, just execute the function without nesting batches
388
559
  return batchFn();
389
560
  }
390
-
561
+
391
562
  this._batchingLock = true;
392
563
  this._batchingEnabled = true;
393
564
  this._pendingUpdates = [];
394
565
 
395
566
  try {
396
567
  const result = batchFn();
397
-
568
+
398
569
  // Process all batched updates
399
570
  if (this._pendingUpdates.length > 0) {
400
571
  // Only notify once with the final state
401
- const finalUpdate = this._pendingUpdates[this._pendingUpdates.length - 1];
402
- this._observer.notify(finalUpdate.newState, finalUpdate.oldState, finalUpdate.action);
572
+ const finalUpdate =
573
+ this._pendingUpdates[this._pendingUpdates.length - 1];
574
+ this._observer.notify(
575
+ finalUpdate.newState,
576
+ finalUpdate.oldState,
577
+ finalUpdate.action,
578
+ );
403
579
  this.lastUpdate = Date.now();
404
580
  }
405
-
581
+
406
582
  return result;
407
583
  } finally {
408
584
  this._batchingEnabled = false;
package/src/Cubit.ts CHANGED
@@ -8,6 +8,20 @@ import { BlocBase } from './BlocBase';
8
8
  * @template P - The type of parameters (optional, defaults to null)
9
9
  */
10
10
  export abstract class Cubit<S, P = null> extends BlocBase<S, P> {
11
+ /**
12
+ * @internal
13
+ * Protected method for useBloc to call
14
+ */
15
+ protected _updateProps(props: P): void {
16
+ const oldProps = this.props;
17
+ this.props = props;
18
+ this.onPropsChanged?.(oldProps as P | undefined, props);
19
+ }
20
+
21
+ /**
22
+ * Optional override for props handling
23
+ */
24
+ protected onPropsChanged?(oldProps: P | undefined, newProps: P): void;
11
25
  /**
12
26
  * Updates the current state and notifies all observers of the change.
13
27
  * If the new state is identical to the current state (using Object.is),
@@ -27,7 +41,7 @@ export abstract class Cubit<S, P = null> extends BlocBase<S, P> {
27
41
  /**
28
42
  * Partially updates the current state by merging it with the provided state patch.
29
43
  * This method is only applicable when the state is an object type.
30
- *
44
+ *
31
45
  * @param statePatch - A partial state object containing only the properties to update
32
46
  * @param ignoreChangeCheck - If true, skips checking if the state has actually changed
33
47
  * @throws {TypeError} If the state is not an object type