@blac/core 2.0.0-rc-10 → 2.0.0-rn-12

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-rn-12",
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,
@@ -20,6 +13,12 @@ import {
20
13
  export interface BlacConfig {
21
14
  /** Whether to expose the Blac instance globally */
22
15
  exposeBlacInstance?: boolean;
16
+ /**
17
+ * Whether to enable proxy dependency tracking for automatic re-render optimization.
18
+ * When false, state changes always cause re-renders unless dependencies are manually specified.
19
+ * Default: true
20
+ */
21
+ proxyDependencyTracking?: boolean;
23
22
  }
24
23
 
25
24
  export interface GetBlocOptions<B extends BlocBase<unknown>> {
@@ -108,6 +107,46 @@ export class Blac {
108
107
  static get getAllBlocs() {
109
108
  return Blac.instance.getAllBlocs;
110
109
  }
110
+
111
+ /** Private static configuration */
112
+ private static _config: BlacConfig = {
113
+ exposeBlacInstance: false,
114
+ proxyDependencyTracking: true,
115
+ };
116
+
117
+ /** Get current configuration */
118
+ static get config(): Readonly<BlacConfig> {
119
+ return { ...this._config };
120
+ }
121
+
122
+ /**
123
+ * Set or update Blac configuration
124
+ * @param config - Partial configuration to merge with existing config
125
+ * @throws Error if configuration is invalid
126
+ */
127
+ static setConfig(config: Partial<BlacConfig>): void {
128
+ // Validate config
129
+ if (config.proxyDependencyTracking !== undefined &&
130
+ typeof config.proxyDependencyTracking !== 'boolean') {
131
+ throw new Error('BlacConfig.proxyDependencyTracking must be a boolean');
132
+ }
133
+
134
+ if (config.exposeBlacInstance !== undefined &&
135
+ typeof config.exposeBlacInstance !== 'boolean') {
136
+ throw new Error('BlacConfig.exposeBlacInstance must be a boolean');
137
+ }
138
+
139
+ // Merge with existing config
140
+ this._config = {
141
+ ...this._config,
142
+ ...config,
143
+ };
144
+
145
+ // Log config update if logging is enabled
146
+ if (this.enableLog) {
147
+ this.instance.log('Blac config updated:', this._config);
148
+ }
149
+ }
111
150
  /** Map storing all registered bloc instances by their class name and ID */
112
151
  blocInstanceMap: Map<string, BlocBase<unknown>> = new Map();
113
152
  /** Map storing isolated bloc instances grouped by their constructor */
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,8 @@ 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
58
  this._eventQueue.push(action);
82
59
 
83
- // If not already processing, start processing the queue
84
60
  if (!this._isProcessingEvent) {
85
61
  await this._processEventQueue();
86
62
  }
@@ -91,7 +67,6 @@ export abstract class Bloc<
91
67
  * Processes events from the queue sequentially
92
68
  */
93
69
  private async _processEventQueue(): Promise<void> {
94
- // Prevent concurrent processing
95
70
  if (this._isProcessingEvent) {
96
71
  return;
97
72
  }
@@ -113,34 +88,18 @@ export abstract class Bloc<
113
88
  * Processes a single event
114
89
  */
115
90
  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
91
  const eventConstructor = action.constructor as new (...args: any[]) => A;
123
92
  const handler = this.eventHandlers.get(eventConstructor);
124
93
 
125
94
  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
95
  const emit = (newState: S): void => {
130
96
  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
97
  this._pushState(newState, previousState, action);
134
98
  };
135
99
 
136
100
  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
101
  await handler(action, emit);
142
102
  } catch (error) {
143
- // Enhanced error handling with better context
144
103
  const constructorName =
145
104
  (action.constructor as { name?: string }).name ||
146
105
  'UnnamedConstructor';
@@ -160,19 +119,11 @@ export abstract class Bloc<
160
119
  errorContext,
161
120
  );
162
121
 
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
168
-
169
- // Optional: Re-throw for critical errors (can be configured)
170
122
  if (error instanceof Error && error.name === 'CriticalError') {
171
123
  throw error;
172
124
  }
173
125
  }
174
126
  } else {
175
- // Enhanced warning with more context
176
127
  const constructorName =
177
128
  (action.constructor as { name?: string }).name || 'UnnamedConstructor';
178
129
  Blac.warn(
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
+ });
@@ -0,0 +1,256 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { Bloc } from '../Bloc';
3
+ import { Cubit } from '../Cubit';
4
+ import { PropsUpdated } from '../events';
5
+ import { BlacAdapter } from '../adapter/BlacAdapter';
6
+ import { Blac } from '../Blac';
7
+
8
+ // Test types
9
+ interface TestProps {
10
+ query: string;
11
+ filters?: string[];
12
+ }
13
+
14
+ interface TestState {
15
+ data: string;
16
+ loading: boolean;
17
+ }
18
+
19
+ // Test Cubit with props
20
+ class TestCubit extends Cubit<TestState, TestProps> {
21
+ onPropsChangedMock = vi.fn();
22
+
23
+ constructor() {
24
+ super({ data: '', loading: false });
25
+ }
26
+
27
+ protected onPropsChanged(oldProps: TestProps | undefined, newProps: TestProps): void {
28
+ this.onPropsChangedMock(oldProps, newProps);
29
+ if (oldProps?.query !== newProps.query) {
30
+ this.emit({ ...this.state, data: `Query: ${newProps.query}` });
31
+ }
32
+ }
33
+
34
+ loadData = () => {
35
+ const query = this.props?.query ?? 'default';
36
+ this.emit({ data: `Loaded: ${query}`, loading: false });
37
+ };
38
+ }
39
+
40
+ // Test Bloc with props
41
+ class TestBloc extends Bloc<TestState, PropsUpdated<TestProps>> {
42
+ constructor() {
43
+ super({ data: '', loading: false });
44
+
45
+ this.on(PropsUpdated<TestProps>, (event, emit) => {
46
+ emit({ ...this.state, data: `Props: ${event.props.query}` });
47
+ });
48
+ }
49
+ }
50
+
51
+ describe('Props functionality', () => {
52
+ beforeEach(() => {
53
+ // Clear Blac instance between tests
54
+ Blac.resetInstance();
55
+ });
56
+
57
+ describe('PropsUpdated event', () => {
58
+ it('should create PropsUpdated event with correct props', () => {
59
+ const props = { query: 'test', filters: ['a', 'b'] };
60
+ const event = new PropsUpdated(props);
61
+
62
+ expect(event.props).toEqual(props);
63
+ expect(event.props).toBe(props); // Should be the same reference
64
+ });
65
+ });
66
+
67
+ describe('Cubit props support', () => {
68
+ it('should support props getter', () => {
69
+ const cubit = new TestCubit();
70
+ expect(cubit.props).toBeNull();
71
+ });
72
+
73
+ it('should update props via _updateProps', () => {
74
+ const cubit = new TestCubit();
75
+ const props = { query: 'test' };
76
+
77
+ (cubit as any)._updateProps(props);
78
+
79
+ expect(cubit.props).toEqual(props);
80
+ expect(cubit.onPropsChangedMock).toHaveBeenCalledWith(null, props);
81
+ });
82
+
83
+ it('should emit state when props change', () => {
84
+ const cubit = new TestCubit();
85
+ const props1 = { query: 'test1' };
86
+ const props2 = { query: 'test2' };
87
+
88
+ (cubit as any)._updateProps(props1);
89
+ expect(cubit.state.data).toBe('Query: test1');
90
+
91
+ (cubit as any)._updateProps(props2);
92
+ expect(cubit.state.data).toBe('Query: test2');
93
+ });
94
+
95
+ it('should access props in methods', () => {
96
+ const cubit = new TestCubit();
97
+
98
+ cubit.loadData();
99
+ expect(cubit.state.data).toBe('Loaded: default');
100
+
101
+ (cubit as any)._updateProps({ query: 'custom' });
102
+ cubit.loadData();
103
+ expect(cubit.state.data).toBe('Loaded: custom');
104
+ });
105
+ });
106
+
107
+ describe('Bloc props support', () => {
108
+ it('should handle PropsUpdated events', async () => {
109
+ const bloc = new TestBloc();
110
+ const props = { query: 'search' };
111
+
112
+ await bloc.add(new PropsUpdated(props));
113
+
114
+ expect(bloc.state.data).toBe('Props: search');
115
+ });
116
+
117
+ it('should queue PropsUpdated events like any other event', async () => {
118
+ const bloc = new TestBloc();
119
+
120
+ await bloc.add(new PropsUpdated({ query: 'first' }));
121
+ await bloc.add(new PropsUpdated({ query: 'second' }));
122
+
123
+ expect(bloc.state.data).toBe('Props: second');
124
+ });
125
+ });
126
+
127
+ describe('BlacAdapter props ownership', () => {
128
+ it('should allow first adapter to own props', () => {
129
+ const Constructor = TestCubit as any;
130
+ const adapter1 = new BlacAdapter(
131
+ { componentRef: { current: {} }, blocConstructor: Constructor },
132
+ { props: { query: 'test1' } }
133
+ );
134
+
135
+ expect(() => {
136
+ adapter1.updateProps({ query: 'test2' });
137
+ }).not.toThrow();
138
+ });
139
+
140
+ it('should prevent non-owner adapters from updating props', () => {
141
+ const warnSpy = vi.spyOn(Blac, 'warn').mockImplementation(() => {});
142
+ const Constructor = TestCubit as any;
143
+
144
+ // First adapter becomes owner
145
+ const adapter1 = new BlacAdapter(
146
+ { componentRef: { current: {} }, blocConstructor: Constructor },
147
+ { props: { query: 'test1' } }
148
+ );
149
+
150
+ // Second adapter tries to update props
151
+ const adapter2 = new BlacAdapter(
152
+ { componentRef: { current: {} }, blocConstructor: Constructor },
153
+ {}
154
+ );
155
+
156
+ adapter2.updateProps({ query: 'hijack' });
157
+
158
+ expect(warnSpy).toHaveBeenCalledWith(
159
+ expect.stringContaining('non-owner adapter')
160
+ );
161
+
162
+ warnSpy.mockRestore();
163
+ });
164
+
165
+ it('should clear ownership on adapter unmount', () => {
166
+ const Constructor = TestCubit as any;
167
+ const adapter1 = new BlacAdapter(
168
+ { componentRef: { current: {} }, blocConstructor: Constructor },
169
+ { props: { query: 'test1' } }
170
+ );
171
+
172
+ adapter1.mount();
173
+ adapter1.unmount();
174
+
175
+ // New adapter should be able to take ownership
176
+ const adapter2 = new BlacAdapter(
177
+ { componentRef: { current: {} }, blocConstructor: Constructor },
178
+ { props: { query: 'test2' } }
179
+ );
180
+
181
+ expect(() => {
182
+ adapter2.updateProps({ query: 'test3' });
183
+ }).not.toThrow();
184
+ });
185
+
186
+ it('should not update props if they are shallowly equal', () => {
187
+ const cubit = new TestCubit();
188
+ const Constructor = TestCubit as any;
189
+ const adapter = new BlacAdapter(
190
+ { componentRef: { current: {} }, blocConstructor: Constructor },
191
+ {}
192
+ );
193
+
194
+ const props = { query: 'test' };
195
+ adapter.updateProps(props);
196
+
197
+ // Reset mock
198
+ cubit.onPropsChangedMock.mockClear();
199
+
200
+ // Update with same props
201
+ adapter.updateProps({ query: 'test' });
202
+
203
+ // onPropsChanged should not have been called
204
+ expect(cubit.onPropsChangedMock).not.toHaveBeenCalled();
205
+ });
206
+
207
+ it('should ignore props updates during disposal', () => {
208
+ const Constructor = TestCubit as any;
209
+ const adapter = new BlacAdapter(
210
+ { componentRef: { current: {} }, blocConstructor: Constructor },
211
+ { props: { query: 'test1' } }
212
+ );
213
+
214
+ // Force disposal state
215
+ (adapter.blocInstance as any)._disposalState = 'disposing';
216
+
217
+ expect(() => {
218
+ adapter.updateProps({ query: 'test2' });
219
+ }).not.toThrow();
220
+
221
+ // Props should not have been updated
222
+ expect((adapter.blocInstance as TestCubit).props?.query).toBe('test1');
223
+ });
224
+ });
225
+
226
+ describe('Props integration', () => {
227
+ it('should dispatch PropsUpdated for Bloc instances', async () => {
228
+ const Constructor = TestBloc as any;
229
+ const adapter = new BlacAdapter(
230
+ { componentRef: { current: {} }, blocConstructor: Constructor },
231
+ {}
232
+ );
233
+
234
+ const bloc = adapter.blocInstance as TestBloc;
235
+ adapter.updateProps({ query: 'adapter-test' });
236
+
237
+ // Wait for event processing
238
+ await new Promise(resolve => setTimeout(resolve, 50));
239
+
240
+ expect(bloc.state.data).toBe('Props: adapter-test');
241
+ });
242
+
243
+ it('should call _updateProps for Cubit instances', () => {
244
+ const Constructor = TestCubit as any;
245
+ const adapter = new BlacAdapter(
246
+ { componentRef: { current: {} }, blocConstructor: Constructor },
247
+ {}
248
+ );
249
+
250
+ adapter.updateProps({ query: 'adapter-test' });
251
+
252
+ expect((adapter.blocInstance as TestCubit).props?.query).toBe('adapter-test');
253
+ expect((adapter.blocInstance as TestCubit).state.data).toBe('Query: adapter-test');
254
+ });
255
+ });
256
+ });