@blac/core 2.0.0-rc-10 → 2.0.0-rc-13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blac/core",
3
- "version": "2.0.0-rc-10",
3
+ "version": "2.0.0-rc-13",
4
4
  "license": "MIT",
5
5
  "author": "Brendan Mullins <jsnanigans@gmail.com>",
6
6
  "main": "dist/index.cjs.js",
package/src/Blac.ts CHANGED
@@ -1,10 +1,3 @@
1
- /* eslint-disable @typescript-eslint/no-explicit-any */
2
- // TODO: The 'any' types in this file are necessary for proper type inference in complex generic scenarios.
3
- // Specifically:
4
- // 1. BlocConstructor<any> in Maps - allows any bloc type to be stored while maintaining type safety in usage
5
- // 2. Type assertions for _disposalState - private property access across inheritance hierarchy
6
- // 3. Constructor argument types - enables flexible bloc instantiation patterns
7
- // These 'any' usages are carefully controlled and don't compromise runtime type safety.
8
1
  import { BlocBase, BlocInstanceId, BlocLifecycleState } from './BlocBase';
9
2
  import {
10
3
  BlocBaseAbstract,
@@ -13,6 +6,7 @@ import {
13
6
  BlocState,
14
7
  InferPropsFromGeneric,
15
8
  } from './types';
9
+ import { SystemPluginRegistry } from './plugins/SystemPluginRegistry';
16
10
 
17
11
  /**
18
12
  * Configuration options for the Blac instance
@@ -20,6 +14,12 @@ import {
20
14
  export interface BlacConfig {
21
15
  /** Whether to expose the Blac instance globally */
22
16
  exposeBlacInstance?: boolean;
17
+ /**
18
+ * Whether to enable proxy dependency tracking for automatic re-render optimization.
19
+ * When false, state changes always cause re-renders unless dependencies are manually specified.
20
+ * Default: true
21
+ */
22
+ proxyDependencyTracking?: boolean;
23
23
  }
24
24
 
25
25
  export interface GetBlocOptions<B extends BlocBase<unknown>> {
@@ -108,6 +108,46 @@ export class Blac {
108
108
  static get getAllBlocs() {
109
109
  return Blac.instance.getAllBlocs;
110
110
  }
111
+
112
+ /** Private static configuration */
113
+ private static _config: BlacConfig = {
114
+ exposeBlacInstance: false,
115
+ proxyDependencyTracking: true,
116
+ };
117
+
118
+ /** Get current configuration */
119
+ static get config(): Readonly<BlacConfig> {
120
+ return { ...this._config };
121
+ }
122
+
123
+ /**
124
+ * Set or update Blac configuration
125
+ * @param config - Partial configuration to merge with existing config
126
+ * @throws Error if configuration is invalid
127
+ */
128
+ static setConfig(config: Partial<BlacConfig>): void {
129
+ // Validate config
130
+ if (config.proxyDependencyTracking !== undefined &&
131
+ typeof config.proxyDependencyTracking !== 'boolean') {
132
+ throw new Error('BlacConfig.proxyDependencyTracking must be a boolean');
133
+ }
134
+
135
+ if (config.exposeBlacInstance !== undefined &&
136
+ typeof config.exposeBlacInstance !== 'boolean') {
137
+ throw new Error('BlacConfig.exposeBlacInstance must be a boolean');
138
+ }
139
+
140
+ // Merge with existing config
141
+ this._config = {
142
+ ...this._config,
143
+ ...config,
144
+ };
145
+
146
+ // Log config update if logging is enabled
147
+ if (this.enableLog) {
148
+ this.instance.log('Blac config updated:', this._config);
149
+ }
150
+ }
111
151
  /** Map storing all registered bloc instances by their class name and ID */
112
152
  blocInstanceMap: Map<string, BlocBase<unknown>> = new Map();
113
153
  /** Map storing isolated bloc instances grouped by their constructor */
@@ -123,6 +163,8 @@ export class Blac {
123
163
  keepAliveBlocs: Set<BlocBase<unknown>> = new Set();
124
164
  /** Flag to control whether changes should be posted to document */
125
165
  postChangesToDocument = false;
166
+ /** System plugin registry */
167
+ readonly plugins = new SystemPluginRegistry();
126
168
 
127
169
  /**
128
170
  * Creates a new Blac instance.
@@ -134,6 +176,9 @@ export class Blac {
134
176
  return Blac.instance;
135
177
  }
136
178
  instanceManager.setInstance(this);
179
+
180
+ // Bootstrap plugins on creation
181
+ this.plugins.bootstrap();
137
182
  }
138
183
 
139
184
  /** Flag to enable/disable logging */
@@ -274,6 +319,9 @@ export class Blac {
274
319
  // First dispose the bloc to prevent further operations
275
320
  bloc._dispose();
276
321
 
322
+ // Notify plugins of bloc disposal
323
+ this.plugins.notifyBlocDisposed(bloc);
324
+
277
325
  // Then clean up from registries
278
326
  if (base.isolated) {
279
327
  this.unregisterIsolatedBlocInstance(bloc);
@@ -468,10 +516,16 @@ export class Blac {
468
516
 
469
517
  if (newBloc.isIsolated) {
470
518
  this.registerIsolatedBlocInstance(newBloc);
471
- return newBloc;
519
+ } else {
520
+ this.registerBlocInstance(newBloc);
472
521
  }
473
522
 
474
- this.registerBlocInstance(newBloc);
523
+ // Activate bloc plugins
524
+ newBloc._activatePlugins();
525
+
526
+ // Notify system plugins of bloc creation
527
+ this.plugins.notifyBlocCreated(newBloc);
528
+
475
529
  return newBloc;
476
530
  }
477
531
 
@@ -747,4 +801,35 @@ export class Blac {
747
801
  static get validateConsumers() {
748
802
  return Blac.instance.validateConsumers;
749
803
  }
804
+
805
+ /**
806
+ * Bootstrap the Blac instance and all plugins
807
+ */
808
+ bootstrap(): void {
809
+ this.plugins.bootstrap();
810
+ }
811
+
812
+ /**
813
+ * Shutdown the Blac instance and all plugins
814
+ */
815
+ shutdown(): void {
816
+ this.plugins.shutdown();
817
+
818
+ // Dispose all non-keepAlive blocs
819
+ for (const bloc of this.blocInstanceMap.values()) {
820
+ if (!bloc._keepAlive) {
821
+ this.disposeBloc(bloc);
822
+ }
823
+ }
824
+
825
+ for (const blocs of this.isolatedBlocMap.values()) {
826
+ for (const bloc of blocs) {
827
+ if (!bloc._keepAlive) {
828
+ this.disposeBloc(bloc);
829
+ }
830
+ }
831
+ }
832
+ }
833
+
834
+
750
835
  }
@@ -174,6 +174,9 @@ export class BlacObservable<S = unknown> {
174
174
  void observer.fn(newState, oldState, action);
175
175
  }
176
176
  });
177
+
178
+ // Notify system plugins of state change
179
+ Blac.instance.plugins.notifyStateChanged(this.bloc, oldState, newState);
177
180
  }
178
181
 
179
182
  /**
package/src/Bloc.ts CHANGED
@@ -2,27 +2,13 @@ import { Blac } from './Blac';
2
2
  import { BlocBase } from './BlocBase';
3
3
  import { BlocEventConstraint } from './types';
4
4
 
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.
7
- // P is for props, changed from any to unknown.
8
5
  export abstract class Bloc<
9
6
  S, // State type
10
7
  A extends BlocEventConstraint = BlocEventConstraint, // Base Action/Event type with proper constraints
11
8
  P = unknown, // Props type
12
9
  > extends BlocBase<S, P> {
13
- // Stores handlers: Map<EventConstructor (subtype of A), HandlerFunction>
14
- // The handler's event parameter will be correctly typed to the specific EventConstructor
15
- // by the 'on' method's signature.
16
10
  readonly eventHandlers: Map<
17
- // Key: Constructor of a specific event E (where E extends A)
18
- // TODO: 'any[]' is required for constructor arguments to allow flexible event instantiation.
19
- // Using specific parameter types would break type inference for events with different
20
- // constructor signatures. The 'any[]' enables polymorphic event handling while
21
- // maintaining type safety through the generic constraint 'E extends A'.
22
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
23
11
  new (...args: any[]) => A,
24
- // Value: Handler function. 'event: A' is used here for the stored function type.
25
- // The 'on' method ensures the specific handler (event: E) is correctly typed.
26
12
  (event: A, emit: (newState: S) => void) => void | Promise<void>
27
13
  > = new Map();
28
14
 
@@ -46,22 +32,14 @@ export abstract class Bloc<
46
32
  * The 'event' parameter in the handler will be typed to the specific eventConstructor.
47
33
  */
48
34
  protected on<E extends A>(
49
- // TODO: 'any[]' is required for constructor arguments (see explanation above).
50
- // This allows events with different constructor signatures to be handled uniformly.
51
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
52
35
  eventConstructor: new (...args: any[]) => E,
53
36
  handler: (event: E, emit: (newState: S) => void) => void | Promise<void>,
54
37
  ): void {
55
38
  if (this.eventHandlers.has(eventConstructor)) {
56
- // Using Blac.warn or a similar logging mechanism from BlocBase if available,
57
- // otherwise console.warn. Assuming this._name and this._id are available from BlocBase.
58
39
  Blac.warn(
59
40
  `[Bloc ${this._name}:${String(this._id)}] Handler for event '${eventConstructor.name}' already registered. It will be overwritten.`,
60
41
  );
61
42
  }
62
- // Cast the specific handler (event: E) to a more general (event: A) for storage.
63
- // This is safe because E extends A. When the handler is called with an 'action' of type A,
64
- // if it was originally registered for type E, 'action' must be an instance of E.
65
43
  this.eventHandlers.set(
66
44
  eventConstructor,
67
45
  handler as (
@@ -77,10 +55,32 @@ export abstract class Bloc<
77
55
  * @param action The action/event instance to be processed.
78
56
  */
79
57
  public add = async (action: A): Promise<void> => {
80
- // Add event to queue
81
- this._eventQueue.push(action);
58
+ // Transform event through plugins
59
+ let transformedAction: A | null = action;
60
+ try {
61
+ transformedAction = (this._plugins as any).transformEvent(action);
62
+ } catch (error) {
63
+ console.error('Error transforming event:', error);
64
+ // Continue with original event if transformation fails
65
+ }
66
+
67
+ // If event was cancelled by plugin, don't process it
68
+ if (transformedAction === null) {
69
+ return;
70
+ }
71
+
72
+ // Notify bloc plugins of event
73
+ try {
74
+ (this._plugins as any).notifyEvent(transformedAction);
75
+ } catch (error) {
76
+ console.error('Error notifying plugins of event:', error);
77
+ }
78
+
79
+ // Notify system plugins of event
80
+ Blac.instance.plugins.notifyEventAdded(this as any, transformedAction);
81
+
82
+ this._eventQueue.push(transformedAction);
82
83
 
83
- // If not already processing, start processing the queue
84
84
  if (!this._isProcessingEvent) {
85
85
  await this._processEventQueue();
86
86
  }
@@ -91,7 +91,6 @@ export abstract class Bloc<
91
91
  * Processes events from the queue sequentially
92
92
  */
93
93
  private async _processEventQueue(): Promise<void> {
94
- // Prevent concurrent processing
95
94
  if (this._isProcessingEvent) {
96
95
  return;
97
96
  }
@@ -113,34 +112,18 @@ export abstract class Bloc<
113
112
  * Processes a single event
114
113
  */
115
114
  private async _processEvent(action: A): Promise<void> {
116
- // Using 'any[]' for constructor arguments for broader compatibility.
117
- // TODO: Type assertion required to cast action.constructor to proper event constructor type.
118
- // JavaScript's constructor property returns 'Function', but we need the specific event
119
- // constructor type to look up handlers. This is safe because we validate the action
120
- // extends the BlocEventConstraint interface.
121
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
122
115
  const eventConstructor = action.constructor as new (...args: any[]) => A;
123
116
  const handler = this.eventHandlers.get(eventConstructor);
124
117
 
125
118
  if (handler) {
126
- // Define the 'emit' function that handlers will use to update state.
127
- // It captures the current state ('this.state') right before each emission
128
- // to provide the correct 'previousState' to _pushState.
129
119
  const emit = (newState: S): void => {
130
120
  const previousState = this.state; // State just before this specific emission
131
- // The 'action' passed to _pushState is the original action that triggered the handler,
132
- // providing context for the state change (e.g., for logging or plugins).
133
121
  this._pushState(newState, previousState, action);
134
122
  };
135
123
 
136
124
  try {
137
- // Await the handler in case it's an async function (e.g., performs API calls).
138
- // The 'action' is passed to the handler, and due to the way 'on' is typed,
139
- // the 'event' parameter within the handler function will be correctly
140
- // typed to its specific class (e.g., LoadMyFeatureData).
141
125
  await handler(action, emit);
142
126
  } catch (error) {
143
- // Enhanced error handling with better context
144
127
  const constructorName =
145
128
  (action.constructor as { name?: string }).name ||
146
129
  'UnnamedConstructor';
@@ -160,19 +143,30 @@ export abstract class Bloc<
160
143
  errorContext,
161
144
  );
162
145
 
163
- // TODO: Consider implementing error boundary pattern
164
- // For now, we log and continue, but applications may want to:
165
- // 1. Emit an error state
166
- // 2. Re-throw the error
167
- // 3. Call an error handler callback
146
+ // Notify plugins of the error
147
+ if (error instanceof Error) {
148
+ try {
149
+ this._plugins.notifyError(error, {
150
+ phase: 'event-processing',
151
+ operation: 'handler',
152
+ metadata: { event: action }
153
+ });
154
+
155
+ Blac.instance.plugins.notifyError(error, this as any, {
156
+ phase: 'event-processing',
157
+ operation: 'handler',
158
+ metadata: { event: action }
159
+ });
160
+ } catch (pluginError) {
161
+ console.error('Error notifying plugins:', pluginError);
162
+ }
163
+ }
168
164
 
169
- // Optional: Re-throw for critical errors (can be configured)
170
165
  if (error instanceof Error && error.name === 'CriticalError') {
171
166
  throw error;
172
167
  }
173
168
  }
174
169
  } else {
175
- // Enhanced warning with more context
176
170
  const constructorName =
177
171
  (action.constructor as { name?: string }).name || 'UnnamedConstructor';
178
172
  Blac.warn(
package/src/BlocBase.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { BlacObservable } from './BlacObserver';
2
2
  import { generateUUID } from './utils/uuid';
3
+ import { BlocPlugin, ErrorContext } from './plugins/types';
4
+ import { BlocPluginRegistry } from './plugins/BlocPluginRegistry';
3
5
 
4
6
  export type BlocInstanceId = string | number | undefined;
5
7
  type DependencySelector<S> = (
@@ -32,6 +34,7 @@ export interface StateTransitionResult {
32
34
  interface BlocStaticProperties {
33
35
  isolated: boolean;
34
36
  keepAlive: boolean;
37
+ plugins?: BlocPlugin<any, any>[];
35
38
  }
36
39
 
37
40
  /**
@@ -158,6 +161,11 @@ export abstract class BlocBase<S, P = unknown> {
158
161
  */
159
162
  private _consumerRefs = new Map<string, WeakRef<object>>();
160
163
 
164
+ /**
165
+ * Plugin registry for this bloc instance
166
+ */
167
+ protected _plugins: BlocPluginRegistry<S, any>;
168
+
161
169
  /**
162
170
  * @internal
163
171
  * Validates that all consumer references are still alive
@@ -210,6 +218,16 @@ export abstract class BlocBase<S, P = unknown> {
210
218
  : false;
211
219
  this._isolated =
212
220
  typeof Constructor.isolated === 'boolean' ? Constructor.isolated : false;
221
+
222
+ // Initialize plugin registry
223
+ this._plugins = new BlocPluginRegistry<S, any>();
224
+
225
+ // Register static plugins
226
+ if (Constructor.plugins && Array.isArray(Constructor.plugins)) {
227
+ for (const plugin of Constructor.plugins) {
228
+ this.addPlugin(plugin);
229
+ }
230
+ }
213
231
  }
214
232
 
215
233
  /**
@@ -529,25 +547,55 @@ export abstract class BlocBase<S, P = unknown> {
529
547
  return;
530
548
  }
531
549
 
550
+ // Transform state through plugins
551
+ let transformedState: S = newState;
552
+ try {
553
+ const result = this._plugins.transformState(oldState, newState);
554
+ transformedState = result;
555
+ } catch (error) {
556
+ this._plugins.notifyError(error as Error, {
557
+ phase: 'state-change',
558
+ operation: 'transformState'
559
+ });
560
+ // Continue with original state if transformation fails
561
+ }
562
+
532
563
  if (this._batchingEnabled) {
533
564
  // When batching, just accumulate the updates
534
- this._pendingUpdates.push({ newState, oldState, action });
565
+ this._pendingUpdates.push({ newState: transformedState, oldState, action });
535
566
 
536
567
  // Update internal state for consistency
537
568
  this._oldState = oldState;
538
- this._state = newState;
569
+ this._state = transformedState;
539
570
  return;
540
571
  }
541
572
 
542
573
  // Normal state update flow
543
574
  this._oldState = oldState;
544
- this._state = newState;
575
+ this._state = transformedState;
576
+
577
+ // Notify bloc plugins first
578
+ try {
579
+ this._plugins.notifyStateChange(oldState, transformedState);
580
+ } catch (error) {
581
+ console.error('Error notifying bloc plugins of state change:', error);
582
+ }
545
583
 
546
584
  // Notify observers of the state change
547
- this._observer.notify(newState, oldState, action);
585
+ this._observer.notify(transformedState, oldState, action);
548
586
  this.lastUpdate = Date.now();
549
587
  };
550
588
 
589
+ /**
590
+ * Notify observers of a state change
591
+ * @internal Used by plugins for state hydration
592
+ * @param newState The new state
593
+ * @param oldState The old state
594
+ */
595
+ _notifyObservers(newState: S, oldState: S): void {
596
+ this._observer.notify(newState, oldState);
597
+ }
598
+
551
599
  /**
552
600
  * Enables batching for multiple state updates
553
601
  * @param batchFn Function to execute with batching enabled
@@ -586,4 +634,59 @@ export abstract class BlocBase<S, P = unknown> {
586
634
  this._pendingUpdates = [];
587
635
  }
588
636
  };
637
+
638
+ /**
639
+ * Add a plugin to this bloc instance
640
+ */
641
+ addPlugin(plugin: BlocPlugin<S, any>): void {
642
+ this._plugins.add(plugin);
643
+
644
+ // Attach if already active
645
+ if (this._disposalState === BlocLifecycleState.ACTIVE) {
646
+ try {
647
+ if (plugin.onAttach) {
648
+ plugin.onAttach(this);
649
+ }
650
+ } catch (error) {
651
+ console.error(`Failed to attach plugin '${plugin.name}':`, error);
652
+ this._plugins.remove(plugin.name);
653
+ throw error;
654
+ }
655
+ }
656
+ }
657
+
658
+ /**
659
+ * Remove a plugin from this bloc instance
660
+ */
661
+ removePlugin(pluginName: string): boolean {
662
+ return this._plugins.remove(pluginName);
663
+ }
664
+
665
+ /**
666
+ * Get a plugin by name
667
+ */
668
+ getPlugin(pluginName: string): BlocPlugin<S, any> | undefined {
669
+ return this._plugins.get(pluginName);
670
+ }
671
+
672
+ /**
673
+ * Get all plugins
674
+ */
675
+ getPlugins(): ReadonlyArray<BlocPlugin<S, any>> {
676
+ return this._plugins.getAll();
677
+ }
678
+
679
+ /**
680
+ * @internal
681
+ * Activate plugins when bloc becomes active
682
+ */
683
+ _activatePlugins(): void {
684
+ if (this._disposalState === BlocLifecycleState.ACTIVE) {
685
+ try {
686
+ this._plugins.attach(this);
687
+ } catch (error) {
688
+ console.error(`Failed to activate plugins for ${this._name}:`, error);
689
+ }
690
+ }
691
+ }
589
692
  }
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),
@@ -0,0 +1,86 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { Blac, BlacConfig } from '../index';
3
+
4
+ describe('Blac.config', () => {
5
+ const originalConfig = { ...Blac.config };
6
+
7
+ beforeEach(() => {
8
+ // Reset to default config before each test
9
+ Blac.setConfig({
10
+ exposeBlacInstance: false,
11
+ proxyDependencyTracking: true,
12
+ });
13
+ });
14
+
15
+ afterEach(() => {
16
+ // Restore original config after tests
17
+ Blac.setConfig(originalConfig);
18
+ });
19
+
20
+ describe('setConfig', () => {
21
+ it('should have default configuration', () => {
22
+ expect(Blac.config).toEqual({
23
+ exposeBlacInstance: false,
24
+ proxyDependencyTracking: true,
25
+ });
26
+ });
27
+
28
+ it('should update configuration with partial config', () => {
29
+ Blac.setConfig({ proxyDependencyTracking: false });
30
+
31
+ expect(Blac.config).toEqual({
32
+ exposeBlacInstance: false,
33
+ proxyDependencyTracking: false,
34
+ });
35
+ });
36
+
37
+ it('should merge configuration properly', () => {
38
+ Blac.setConfig({ exposeBlacInstance: true });
39
+ Blac.setConfig({ proxyDependencyTracking: false });
40
+
41
+ expect(Blac.config).toEqual({
42
+ exposeBlacInstance: true,
43
+ proxyDependencyTracking: false,
44
+ });
45
+ });
46
+
47
+ it('should throw error for invalid proxyDependencyTracking type', () => {
48
+ expect(() => {
49
+ Blac.setConfig({ proxyDependencyTracking: 'true' as any });
50
+ }).toThrow('BlacConfig.proxyDependencyTracking must be a boolean');
51
+ });
52
+
53
+ it('should throw error for invalid exposeBlacInstance type', () => {
54
+ expect(() => {
55
+ Blac.setConfig({ exposeBlacInstance: 1 as any });
56
+ }).toThrow('BlacConfig.exposeBlacInstance must be a boolean');
57
+ });
58
+
59
+ it('should return a copy of config, not the original', () => {
60
+ const config1 = Blac.config;
61
+ const config2 = Blac.config;
62
+
63
+ expect(config1).not.toBe(config2);
64
+ expect(config1).toEqual(config2);
65
+ });
66
+
67
+ it('should not allow direct modification of config', () => {
68
+ const config = Blac.config as any;
69
+ config.proxyDependencyTracking = false;
70
+
71
+ // Original config should remain unchanged
72
+ expect(Blac.config.proxyDependencyTracking).toBe(true);
73
+ });
74
+ });
75
+
76
+ describe('config type exports', () => {
77
+ it('should allow typed config usage', () => {
78
+ const testConfig: Partial<BlacConfig> = {
79
+ proxyDependencyTracking: false,
80
+ };
81
+
82
+ Blac.setConfig(testConfig);
83
+ expect(Blac.config.proxyDependencyTracking).toBe(false);
84
+ });
85
+ });
86
+ });