@blac/core 2.0.0-rc-4 → 2.0.0-rc-6

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.
@@ -61,12 +61,7 @@ export class BlacObservable<S = unknown> {
61
61
  subscribe(observer: BlacObserver<S>): () => void {
62
62
  Blac.log('BlacObservable.subscribe: Subscribing observer.', this.bloc, observer);
63
63
  this._observers.add(observer);
64
- // Blac.instance.dispatchEvent(BlacLifecycleEvent.LISTENER_ADDED, this.bloc, { listenerId: observer.id });
65
- if (!observer.lastState) {
66
- observer.lastState = observer.dependencyArray
67
- ? observer.dependencyArray(this.bloc.state)
68
- : [];
69
- }
64
+ // Don't initialize lastState here - let it remain undefined for first-time detection
70
65
  return () => {
71
66
  Blac.log('BlacObservable.subscribe: Unsubscribing observer.', this.bloc, observer);
72
67
  this.unsubscribe(observer);
@@ -82,8 +77,8 @@ export class BlacObservable<S = unknown> {
82
77
  // Blac.instance.dispatchEvent(BlacLifecycleEvent.LISTENER_REMOVED, this.bloc, { listenerId: observer.id });
83
78
 
84
79
  if (this.size === 0) {
85
- Blac.log('BlacObservable.unsubscribe: No observers left. Disposing bloc.', this.bloc);
86
- this.bloc._dispose();
80
+ Blac.log('BlacObservable.unsubscribe: No observers left.', this.bloc);
81
+ // The bloc will handle its own disposal through consumer management
87
82
  }
88
83
  }
89
84
 
@@ -98,16 +93,37 @@ export class BlacObservable<S = unknown> {
98
93
  let shouldUpdate = false;
99
94
 
100
95
  if (observer.dependencyArray) {
101
- const lastDependencyCheck = observer.lastState || [];
96
+ const lastDependencyCheck = observer.lastState;
102
97
  const newDependencyCheck = observer.dependencyArray(newState);
103
98
 
104
- for (let o = 0; o < newDependencyCheck.length; o++) {
105
- const partNew = newDependencyCheck[o];
106
- const partOld = lastDependencyCheck[o] || [];
107
- for (let i = 0; i < partNew.length; i++) {
108
- if (!Object.is(partNew[i], partOld[i])) {
109
- shouldUpdate = true;
110
- break;
99
+ // If this is the first time (no lastState), always update
100
+ if (!lastDependencyCheck) {
101
+ shouldUpdate = true;
102
+ } else {
103
+ // Compare dependency arrays for changes
104
+ if (lastDependencyCheck.length !== newDependencyCheck.length) {
105
+ shouldUpdate = true;
106
+ } else {
107
+ // Compare each part of the dependency arrays
108
+ for (let o = 0; o < newDependencyCheck.length; o++) {
109
+ const partNew = newDependencyCheck[o];
110
+ const partOld = lastDependencyCheck[o] || [];
111
+
112
+ // If the part lengths are different, definitely update
113
+ if (partNew.length !== partOld.length) {
114
+ shouldUpdate = true;
115
+ break;
116
+ }
117
+
118
+ // Compare each value in the parts
119
+ for (let i = 0; i < partNew.length; i++) {
120
+ if (!Object.is(partNew[i], partOld[i])) {
121
+ shouldUpdate = true;
122
+ break;
123
+ }
124
+ }
125
+
126
+ if (shouldUpdate) break;
111
127
  }
112
128
  }
113
129
  }
@@ -127,9 +143,7 @@ export class BlacObservable<S = unknown> {
127
143
  * Clears the observer set
128
144
  */
129
145
  clear() {
130
- this._observers.forEach((observer) => {
131
- this.unsubscribe(observer);
132
- });
146
+ // Just clear the observers without calling unsubscribe to avoid circular disposal
133
147
  this._observers.clear();
134
148
  }
135
149
  }
package/src/Bloc.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  import { Blac } from './Blac';
2
2
  import { BlocBase } from './BlocBase';
3
+ import { BlocEventConstraint } from './types';
3
4
 
4
- // A should be the base type for all events this Bloc handles and must be an object type
5
- // to access action.constructor. Events are typically class instances.
5
+ // A should be the base type for all events this Bloc handles and must extend BlocEventConstraint
6
+ // to access action.constructor and ensure proper event structure.
6
7
  // P is for props, changed from any to unknown.
7
8
  export abstract class Bloc<
8
9
  S, // State type
9
- A extends object, // Base Action/Event type, constrained to object
10
+ A extends BlocEventConstraint = BlocEventConstraint, // Base Action/Event type with proper constraints
10
11
  P = unknown // Props type
11
12
  > extends BlocBase<S, P> {
12
13
  // Stores handlers: Map<EventConstructor (subtype of A), HandlerFunction>
@@ -22,6 +23,18 @@ export abstract class Bloc<
22
23
  (event: A, emit: (newState: S) => void) => void | Promise<void>
23
24
  > = new Map();
24
25
 
26
+ /**
27
+ * @internal
28
+ * Event queue to ensure sequential processing of async events
29
+ */
30
+ private _eventQueue: A[] = [];
31
+
32
+ /**
33
+ * @internal
34
+ * Flag indicating if an event is currently being processed
35
+ */
36
+ private _isProcessingEvent = false;
37
+
25
38
  /**
26
39
  * Registers an event handler for a specific event type.
27
40
  * This method is typically called in the constructor of a derived Bloc class.
@@ -53,11 +66,46 @@ export abstract class Bloc<
53
66
 
54
67
  /**
55
68
  * Dispatches an action/event to the Bloc.
56
- * If a handler is registered for this specific event type (via 'on'), it will be invoked.
57
- * Asynchronous handlers are awaited.
69
+ * Events are queued and processed sequentially to prevent race conditions.
58
70
  * @param action The action/event instance to be processed.
59
71
  */
60
72
  public add = async (action: A): Promise<void> => {
73
+ // Add event to queue
74
+ this._eventQueue.push(action);
75
+
76
+ // If not already processing, start processing the queue
77
+ if (!this._isProcessingEvent) {
78
+ await this._processEventQueue();
79
+ }
80
+ };
81
+
82
+ /**
83
+ * @internal
84
+ * Processes events from the queue sequentially
85
+ */
86
+ private async _processEventQueue(): Promise<void> {
87
+ // Prevent concurrent processing
88
+ if (this._isProcessingEvent) {
89
+ return;
90
+ }
91
+
92
+ this._isProcessingEvent = true;
93
+
94
+ try {
95
+ while (this._eventQueue.length > 0) {
96
+ const action = this._eventQueue.shift()!;
97
+ await this._processEvent(action);
98
+ }
99
+ } finally {
100
+ this._isProcessingEvent = false;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * @internal
106
+ * Processes a single event
107
+ */
108
+ private async _processEvent(action: A): Promise<void> {
61
109
  // Using 'any[]' for constructor arguments for broader compatibility.
62
110
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
63
111
  const eventConstructor = action.constructor as new (...args: any[]) => A;
@@ -81,29 +129,42 @@ export abstract class Bloc<
81
129
  // typed to its specific class (e.g., LoadMyFeatureData).
82
130
  await handler(action, emit);
83
131
  } catch (error) {
84
- // It's good practice to handle errors occurring within event handlers.
132
+ // Enhanced error handling with better context
133
+ const constructorName = (action.constructor as { name?: string }).name || 'UnnamedConstructor';
134
+ const errorContext = {
135
+ blocName: this._name,
136
+ blocId: String(this._id),
137
+ eventType: constructorName,
138
+ currentState: this.state,
139
+ action: action,
140
+ timestamp: new Date().toISOString()
141
+ };
142
+
85
143
  Blac.error(
86
- `[Bloc ${this._name}:${String(this._id)}] Error in event handler for '${eventConstructor.name}':`,
144
+ `[Bloc ${this._name}:${String(this._id)}] Error in event handler for '${constructorName}':`,
87
145
  error,
88
- "Action:", action
146
+ "Context:", errorContext
89
147
  );
90
- // Depending on the desired error handling strategy, you might:
91
- // 1. Emit a specific error state: this.emit(new MyErrorState(error));
92
- // 2. Re-throw the error: throw error;
93
- // 3. Log and ignore (as done here by default).
94
- // This should be decided based on application requirements.
148
+
149
+ // TODO: Consider implementing error boundary pattern
150
+ // For now, we log and continue, but applications may want to:
151
+ // 1. Emit an error state
152
+ // 2. Re-throw the error
153
+ // 3. Call an error handler callback
154
+
155
+ // Optional: Re-throw for critical errors (can be configured)
156
+ if (error instanceof Error && error.name === 'CriticalError') {
157
+ throw error;
158
+ }
95
159
  }
96
160
  } else {
97
- // action.constructor.name should be safe due to 'A extends object' and common JS practice for constructors.
98
- // If linting still complains, it might be overly strict for this common pattern.
161
+ // Enhanced warning with more context
99
162
  const constructorName = (action.constructor as { name?: string }).name || 'UnnamedConstructor';
100
163
  Blac.warn(
101
- `[Bloc ${this._name}:${String(this._id)}] No handler registered for action type: '${constructorName}'. Action was:`,
102
- action
164
+ `[Bloc ${this._name}:${String(this._id)}] No handler registered for action type: '${constructorName}'.`,
165
+ "Registered handlers:", Array.from(this.eventHandlers.keys()).map(k => k.name),
166
+ "Action was:", action
103
167
  );
104
- // If no handler is found, the action is effectively ignored.
105
- // Consider if this is the desired behavior or if an error should be thrown
106
- // or a default handler should be invoked.
107
168
  }
108
- };
169
+ }
109
170
  }
package/src/BlocBase.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { BlacObservable } from './BlacObserver';
2
- import { BlocConstructor } from './types';
2
+ import { generateUUID } from './utils/uuid';
3
3
 
4
4
  export type BlocInstanceId = string | number | undefined;
5
5
  type DependencySelector<S> = (newState: S) => unknown[][];
@@ -22,7 +22,7 @@ export abstract class BlocBase<
22
22
  S,
23
23
  P = unknown
24
24
  > {
25
- public uid = crypto.randomUUID();
25
+ public uid = generateUUID();
26
26
  /**
27
27
  * When true, every consumer will receive its own unique instance of this Bloc.
28
28
  * Use this when state should not be shared between components.
@@ -85,6 +85,12 @@ export abstract class BlocBase<
85
85
  */
86
86
  public readonly _createdAt = Date.now();
87
87
 
88
+ /**
89
+ * @internal
90
+ * Disposal state to prevent race conditions
91
+ */
92
+ private _disposalState: 'active' | 'disposing' | 'disposed' = 'active';
93
+
88
94
  /**
89
95
  * @internal
90
96
  * The current state of the Bloc.
@@ -103,6 +109,50 @@ export abstract class BlocBase<
103
109
  */
104
110
  public props: P | null = null;
105
111
 
112
+ /**
113
+ * @internal
114
+ * Flag to prevent batching race conditions
115
+ */
116
+ private _batchingLock = false;
117
+
118
+ /**
119
+ * @internal
120
+ * Pending batched updates
121
+ */
122
+ private _pendingUpdates: Array<{newState: S, oldState: S, action?: unknown}> = [];
123
+
124
+ /**
125
+ * @internal
126
+ * Map of consumer IDs to their WeakRef objects for proper cleanup
127
+ */
128
+ private _consumerRefs = new Map<string, WeakRef<object>>();
129
+
130
+ /**
131
+ * @internal
132
+ * Validates that all consumer references are still alive
133
+ * Removes dead consumers automatically
134
+ */
135
+ _validateConsumers = (): void => {
136
+ const deadConsumers: string[] = [];
137
+
138
+ for (const [consumerId, weakRef] of this._consumerRefs) {
139
+ if (weakRef.deref() === undefined) {
140
+ deadConsumers.push(consumerId);
141
+ }
142
+ }
143
+
144
+ // Clean up dead consumers
145
+ for (const consumerId of deadConsumers) {
146
+ this._consumers.delete(consumerId);
147
+ this._consumerRefs.delete(consumerId);
148
+ }
149
+
150
+ // Schedule disposal if no live consumers remain
151
+ if (this._consumers.size === 0 && !this._keepAlive && this._disposalState === 'active') {
152
+ this._scheduleDisposal();
153
+ }
154
+ };
155
+
106
156
  /**
107
157
  * Creates a new BlocBase instance with the given initial state.
108
158
  * Sets up the observer, registers with the Blac manager, and initializes addons.
@@ -157,8 +207,15 @@ export abstract class BlocBase<
157
207
  * Notifies the Blac manager and clears all observers.
158
208
  */
159
209
  _dispose() {
160
- // Clear all consumers
210
+ // Prevent re-entrant disposal using atomic state change
211
+ if (this._disposalState !== 'active') {
212
+ return;
213
+ }
214
+ this._disposalState = 'disposing';
215
+
216
+ // Clear all consumers and their references
161
217
  this._consumers.clear();
218
+ this._consumerRefs.clear();
162
219
 
163
220
  // Clear observer subscriptions
164
221
  this._observer.clear();
@@ -166,6 +223,9 @@ export abstract class BlocBase<
166
223
  // Call user-defined disposal hook
167
224
  this.onDispose?.();
168
225
 
226
+ // Mark as fully disposed
227
+ this._disposalState = 'disposed';
228
+
169
229
  // The Blac manager will handle removal from registry
170
230
  // This method is called by Blac.disposeBloc, so we don't need to call it again
171
231
  }
@@ -182,12 +242,6 @@ export abstract class BlocBase<
182
242
  */
183
243
  _consumers = new Set<string>();
184
244
 
185
- /**
186
- * @internal
187
- * WeakSet to track consumer references for cleanup validation
188
- */
189
- private _consumerRefs = new WeakSet<object>();
190
-
191
245
  /**
192
246
  * @internal
193
247
  * Registers a new consumer to this Bloc instance.
@@ -197,11 +251,17 @@ export abstract class BlocBase<
197
251
  * @param consumerRef Optional reference to the consumer object for cleanup validation
198
252
  */
199
253
  _addConsumer = (consumerId: string, consumerRef?: object) => {
254
+ // Prevent adding consumers to disposed blocs
255
+ if (this._disposalState !== 'active') {
256
+ return;
257
+ }
258
+
200
259
  if (this._consumers.has(consumerId)) return;
201
260
  this._consumers.add(consumerId);
202
261
 
262
+ // Store WeakRef for proper memory management
203
263
  if (consumerRef) {
204
- this._consumerRefs.add(consumerRef);
264
+ this._consumerRefs.set(consumerId, new WeakRef(consumerRef));
205
265
  }
206
266
 
207
267
  // this._blac.dispatchEvent(BlacLifecycleEvent.BLOC_CONSUMER_ADDED, this, { consumerId });
@@ -216,11 +276,13 @@ export abstract class BlocBase<
216
276
  */
217
277
  _removeConsumer = (consumerId: string) => {
218
278
  if (!this._consumers.has(consumerId)) return;
279
+
219
280
  this._consumers.delete(consumerId);
281
+ this._consumerRefs.delete(consumerId);
220
282
  // this._blac.dispatchEvent(BlacLifecycleEvent.BLOC_CONSUMER_REMOVED, this, { consumerId });
221
283
 
222
284
  // If no consumers remain and not keep-alive, schedule disposal
223
- if (this._consumers.size === 0 && !this._keepAlive) {
285
+ if (this._consumers.size === 0 && !this._keepAlive && this._disposalState === 'active') {
224
286
  this._scheduleDisposal();
225
287
  }
226
288
  };
@@ -244,16 +306,19 @@ export abstract class BlocBase<
244
306
  * Schedules disposal of this bloc instance if it has no consumers
245
307
  */
246
308
  private _scheduleDisposal() {
247
- // Use setTimeout to avoid disposal during render cycles
248
- setTimeout(() => {
249
- if (this._consumers.size === 0 && !this._keepAlive) {
250
- if (this._disposalHandler) {
251
- this._disposalHandler(this as any);
252
- } else {
253
- this._dispose();
254
- }
309
+ // Prevent multiple disposal attempts
310
+ if (this._disposalState !== 'active') {
311
+ return;
312
+ }
313
+
314
+ // Double-check conditions before disposal
315
+ if (this._consumers.size === 0 && !this._keepAlive) {
316
+ if (this._disposalHandler) {
317
+ this._disposalHandler(this as any);
318
+ } else {
319
+ this._dispose();
255
320
  }
256
- }, 0);
321
+ }
257
322
  }
258
323
 
259
324
  lastUpdate = Date.now();
@@ -264,12 +329,6 @@ export abstract class BlocBase<
264
329
  */
265
330
  private _batchingEnabled = false;
266
331
 
267
- /**
268
- * @internal
269
- * Pending state updates when batching is enabled
270
- */
271
- private _pendingUpdates: Array<{newState: S, oldState: S, action?: unknown}> = [];
272
-
273
332
  /**
274
333
  * @internal
275
334
  * Updates the state and notifies all observers of the change.
@@ -279,28 +338,33 @@ export abstract class BlocBase<
279
338
  * @param action Optional metadata about what caused the state change
280
339
  */
281
340
  _pushState = (newState: S, oldState: S, action?: unknown): void => {
282
- // Runtime validation for state changes
341
+ // Validate newState
283
342
  if (newState === undefined) {
284
343
  console.warn('BlocBase._pushState: newState is undefined', this);
285
344
  return;
286
345
  }
287
-
346
+
288
347
  // Validate action type if provided
289
- if (action !== undefined && action !== null) {
290
- const actionType = typeof action;
291
- if (!(['string', 'object', 'number'].includes(actionType))) {
292
- console.warn('BlocBase._pushState: Invalid action type', this, action);
293
- }
348
+ if (action !== undefined && typeof action !== 'object' && typeof action !== 'function') {
349
+ console.warn('BlocBase._pushState: Invalid action type', this, action);
350
+ return;
294
351
  }
295
352
 
296
- // If batching is enabled, queue the update
297
353
  if (this._batchingEnabled) {
354
+ // When batching, just accumulate the updates
298
355
  this._pendingUpdates.push({ newState, oldState, action });
356
+
357
+ // Update internal state for consistency
358
+ this._oldState = oldState;
359
+ this._state = newState;
299
360
  return;
300
361
  }
301
-
362
+
363
+ // Normal state update flow
302
364
  this._oldState = oldState;
303
365
  this._state = newState;
366
+
367
+ // Notify observers of the state change
304
368
  this._observer.notify(newState, oldState, action);
305
369
  this.lastUpdate = Date.now();
306
370
  };
@@ -310,28 +374,32 @@ export abstract class BlocBase<
310
374
  * @param batchFn Function to execute with batching enabled
311
375
  */
312
376
  batch = <T>(batchFn: () => T): T => {
313
- const wasBatching = this._batchingEnabled;
314
- this._batchingEnabled = true;
377
+ // Prevent batching race conditions
378
+ if (this._batchingLock) {
379
+ // If already batching, just execute the function without nesting batches
380
+ return batchFn();
381
+ }
315
382
 
383
+ this._batchingLock = true;
384
+ this._batchingEnabled = true;
385
+ this._pendingUpdates = [];
386
+
316
387
  try {
317
388
  const result = batchFn();
318
389
 
319
- // Process all pending updates
390
+ // Process all batched updates
320
391
  if (this._pendingUpdates.length > 0) {
321
- const lastUpdate = this._pendingUpdates[this._pendingUpdates.length - 1];
322
- this._oldState = this._pendingUpdates[0].oldState;
323
- this._state = lastUpdate.newState;
324
-
325
- // Notify with the final state
326
- this._observer.notify(lastUpdate.newState, this._oldState, lastUpdate.action);
392
+ // Only notify once with the final state
393
+ const finalUpdate = this._pendingUpdates[this._pendingUpdates.length - 1];
394
+ this._observer.notify(finalUpdate.newState, finalUpdate.oldState, finalUpdate.action);
327
395
  this.lastUpdate = Date.now();
328
-
329
- this._pendingUpdates = [];
330
396
  }
331
397
 
332
398
  return result;
333
399
  } finally {
334
- this._batchingEnabled = wasBatching;
400
+ this._batchingEnabled = false;
401
+ this._batchingLock = false;
402
+ this._pendingUpdates = [];
335
403
  }
336
404
  };
337
405
  }
package/src/index.ts CHANGED
@@ -1,7 +1,14 @@
1
1
  export * from './Blac';
2
+ export * from './BlacEvent';
2
3
  export * from './BlacObserver';
3
4
  export * from './Bloc';
4
5
  export * from './BlocBase';
5
6
  export * from './Cubit';
6
7
  export * from './types';
7
8
 
9
+ // Utilities
10
+ export * from './utils/uuid';
11
+
12
+ // Test utilities
13
+ export * from './testing';
14
+