@blac/react 2.0.0-rc-11 → 2.0.0-rc-14

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.
@@ -1,6 +1,11 @@
1
- import { Blac, Bloc, Cubit, PropsUpdated } from '@blac/core';
1
+ import {
2
+ Blac,
3
+ Bloc,
4
+ BlocConstructorParams,
5
+ Cubit,
6
+ PropsUpdated,
7
+ } from '@blac/core';
2
8
  import { act, renderHook, waitFor } from '@testing-library/react';
3
- import React from 'react';
4
9
  import { describe, expect, it, vi, beforeEach } from 'vitest';
5
10
  import useBloc from '../useBloc';
6
11
 
@@ -16,8 +21,15 @@ interface SearchState {
16
21
  }
17
22
 
18
23
  class SearchBloc extends Bloc<SearchState, PropsUpdated<SearchProps>> {
19
- constructor(private config: { apiEndpoint: string }) {
20
- super({ results: [], loading: false });
24
+ props: BlocConstructorParams<typeof SearchBloc>;
25
+
26
+ constructor(props?: SearchProps) {
27
+ // Initialize with props
28
+ super({
29
+ results: props ? [`Search: ${props.query}`] : [],
30
+ loading: false,
31
+ });
32
+ this.props = props;
21
33
 
22
34
  this.on(PropsUpdated<SearchProps>, (event, emit) => {
23
35
  emit({ results: [`Search: ${event.props.query}`], loading: false });
@@ -34,12 +46,18 @@ interface CounterState {
34
46
  stepSize: number;
35
47
  }
36
48
 
37
- class CounterCubit extends Cubit<CounterState, CounterProps> {
38
- constructor() {
39
- super({ count: 0, stepSize: 1 });
49
+ class CounterCubit extends Cubit<CounterState> {
50
+ constructor(props?: CounterProps) {
51
+ super({ count: 0, stepSize: props?.step ?? 1 });
52
+ this.props = props || null;
40
53
  }
41
54
 
42
- protected onPropsChanged(oldProps: CounterProps | undefined, newProps: CounterProps): void {
55
+ props: CounterProps | null = null;
56
+
57
+ protected onPropsChanged(
58
+ oldProps: CounterProps | undefined,
59
+ newProps: CounterProps,
60
+ ): void {
43
61
  if (oldProps?.step !== newProps.step) {
44
62
  this.emit({ ...this.state, stepSize: newProps.step });
45
63
  }
@@ -47,7 +65,11 @@ class CounterCubit extends Cubit<CounterState, CounterProps> {
47
65
 
48
66
  increment = () => {
49
67
  const step = this.props?.step ?? 1;
50
- this.emit({ ...this.state, count: this.state.count + step, stepSize: step });
68
+ this.emit({
69
+ ...this.state,
70
+ count: this.state.count + step,
71
+ stepSize: step,
72
+ });
51
73
  };
52
74
  }
53
75
 
@@ -59,65 +81,61 @@ describe('useBloc props integration', () => {
59
81
  describe('Basic props functionality', () => {
60
82
  it('should pass props to Bloc via adapter', async () => {
61
83
  const { result } = renderHook(
62
- ({ query }) => useBloc(
63
- SearchBloc,
64
- { props: { apiEndpoint: '/api/search', query } }
65
- ),
66
- { initialProps: { query: 'initial' } }
84
+ ({ query }) => useBloc(SearchBloc, { staticProps: { query } }),
85
+ { initialProps: { query: 'initial' } },
67
86
  );
68
87
 
69
88
  await act(async () => {
70
- await new Promise(resolve => setTimeout(resolve, 0));
89
+ await new Promise((resolve) => setTimeout(resolve, 0));
71
90
  });
72
91
 
73
92
  const [state] = result.current;
74
93
  expect(state.results).toEqual(['Search: initial']);
75
94
  });
76
95
 
77
- it('should update props when they change', async () => {
96
+ it('should create new instance when staticProps change', async () => {
78
97
  const { result, rerender } = renderHook(
79
- ({ query }) => useBloc(
80
- SearchBloc,
81
- { props: { apiEndpoint: '/api/search', query } }
82
- ),
83
- { initialProps: { query: 'initial' } }
98
+ ({ query }) => useBloc(SearchBloc, { staticProps: { query } }),
99
+ { initialProps: { query: 'initial' } },
84
100
  );
85
101
 
86
102
  await act(async () => {
87
- await new Promise(resolve => setTimeout(resolve, 0));
103
+ await new Promise((resolve) => setTimeout(resolve, 0));
88
104
  });
89
105
 
106
+ const firstInstance = result.current[1];
90
107
  expect(result.current[0].results).toEqual(['Search: initial']);
91
108
 
92
109
  rerender({ query: 'updated' });
93
110
 
94
111
  await act(async () => {
95
- await new Promise(resolve => setTimeout(resolve, 0));
112
+ await new Promise((resolve) => setTimeout(resolve, 0));
96
113
  });
97
114
 
115
+ const secondInstance = result.current[1];
116
+ expect(secondInstance).not.toBe(firstInstance); // New instance created
98
117
  expect(result.current[0].results).toEqual(['Search: updated']);
99
118
  });
100
119
 
101
120
  it('should work with Cubit props', async () => {
102
121
  const { result } = renderHook(
103
122
  ({ step }) => {
104
- const [state, cubit] = useBloc(
105
- CounterCubit,
106
- { props: { step } }
107
- );
123
+ const [state, cubit] = useBloc(CounterCubit, {
124
+ staticProps: { step },
125
+ });
108
126
  // Access count to ensure it's tracked
109
127
  void state.count;
110
128
  return [state, cubit];
111
129
  },
112
- { initialProps: { step: 1 } }
130
+ { initialProps: { step: 1 } },
113
131
  );
114
132
 
115
133
  // Wait for props to be set
116
134
  await act(async () => {
117
- await new Promise(resolve => setTimeout(resolve, 0));
135
+ await new Promise((resolve) => setTimeout(resolve, 0));
118
136
  });
119
137
 
120
- const [state, cubit] = result.current;
138
+ const [state, cubit] = result.current as [CounterState, CounterCubit];
121
139
  expect(state.stepSize).toBe(1);
122
140
 
123
141
  act(() => {
@@ -126,138 +144,142 @@ describe('useBloc props integration', () => {
126
144
 
127
145
  // Wait for React to re-render with new state
128
146
  await waitFor(() => {
129
- expect(result.current[0].count).toBe(1);
147
+ expect((result.current[0] as CounterState).count).toBe(1);
130
148
  });
131
149
  });
132
150
 
133
- it('should update Cubit props reactively', async () => {
151
+ it('should create new Cubit instance when staticProps change', async () => {
134
152
  const { result, rerender } = renderHook(
135
153
  ({ step }) => {
136
- const [state, cubit] = useBloc(
137
- CounterCubit,
138
- { props: { step } }
139
- );
154
+ const [state, cubit] = useBloc(CounterCubit, {
155
+ staticProps: { step },
156
+ });
140
157
  // Access count to ensure it's tracked
141
158
  void state.count;
142
159
  return [state, cubit];
143
160
  },
144
- { initialProps: { step: 1 } }
161
+ { initialProps: { step: 1 } },
145
162
  );
146
163
 
147
164
  // Wait for initial props to be set
148
165
  await act(async () => {
149
- await new Promise(resolve => setTimeout(resolve, 0));
166
+ await new Promise((resolve) => setTimeout(resolve, 0));
150
167
  });
151
168
 
152
- expect(result.current[0].stepSize).toBe(1);
169
+ const firstInstance = result.current[1];
170
+ expect((result.current[0] as CounterState).stepSize).toBe(1);
153
171
 
154
172
  rerender({ step: 5 });
155
173
 
156
- // Wait for props update
174
+ // Wait for new instance to be created
157
175
  await act(async () => {
158
- await new Promise(resolve => setTimeout(resolve, 0));
176
+ await new Promise((resolve) => setTimeout(resolve, 0));
159
177
  });
160
178
 
161
- expect(result.current[0].stepSize).toBe(5);
179
+ const secondInstance = result.current[1];
180
+ expect(secondInstance).not.toBe(firstInstance); // New instance
181
+ expect((result.current[0] as CounterState).stepSize).toBe(5);
182
+ expect((result.current[0] as CounterState).count).toBe(0); // New instance starts at 0
162
183
 
163
184
  act(() => {
164
- result.current[1].increment();
185
+ (result.current[1] as CounterCubit).increment();
165
186
  });
166
187
 
167
188
  // Wait for React to re-render with new state
168
189
  await waitFor(() => {
169
- expect(result.current[0].count).toBe(5);
190
+ expect((result.current[0] as CounterState).count).toBe(5);
170
191
  });
171
192
  });
172
193
  });
173
194
 
174
- describe('Props ownership', () => {
175
- it('should enforce single owner for props', async () => {
176
- const warnSpy = vi.spyOn(Blac, 'warn').mockImplementation(() => {});
177
-
178
- // First hook owns props
195
+ describe('Instance sharing', () => {
196
+ it('should share instance when using same instanceId', async () => {
197
+ // First hook creates instance with explicit id
179
198
  const { result: result1 } = renderHook(() =>
180
- useBloc(
181
- SearchBloc,
182
- { props: { apiEndpoint: '/api/search', query: 'owner' } }
183
- )
199
+ useBloc(SearchBloc, {
200
+ staticProps: { query: 'first' },
201
+ instanceId: 'shared-search',
202
+ }),
184
203
  );
185
204
 
186
- // Second hook cannot override props
205
+ // Second hook uses same instanceId - gets same instance
187
206
  const { result: result2 } = renderHook(() =>
188
- useBloc(
189
- SearchBloc,
190
- { props: { apiEndpoint: '/api/search', query: 'hijacker' } }
191
- )
207
+ useBloc(SearchBloc, {
208
+ staticProps: { query: 'second' }, // Different props, but same instanceId
209
+ instanceId: 'shared-search',
210
+ }),
192
211
  );
193
212
 
194
213
  await act(async () => {
195
- await new Promise(resolve => setTimeout(resolve, 0));
214
+ await new Promise((resolve) => setTimeout(resolve, 0));
196
215
  });
197
216
 
198
- expect(warnSpy).toHaveBeenCalledWith(
199
- expect.stringContaining('non-owner adapter')
200
- );
201
-
202
- // Both should see the owner's state
203
- expect(result1.current[0].results).toEqual(['Search: owner']);
204
- expect(result2.current[0].results).toEqual(['Search: owner']);
217
+ // Both should share the same instance (check by ID)
218
+ expect(result1.current[1]._id).toBe('shared-search');
219
+ expect(result2.current[1]._id).toBe('shared-search');
205
220
 
206
- warnSpy.mockRestore();
221
+ // Both see the state from the first instance (query: 'first')
222
+ expect(result1.current[0].results).toEqual(['Search: first']);
223
+ expect(result2.current[0].results).toEqual(['Search: first']);
207
224
  });
208
225
 
209
- it('should allow read-only consumers without props', async () => {
210
- // Owner with props
211
- const { result: ownerResult } = renderHook(() =>
212
- useBloc(
213
- SearchBloc,
214
- { props: { apiEndpoint: '/api/search', query: 'test' } }
215
- )
226
+ it('should allow consumers without staticProps to share instance', async () => {
227
+ // First consumer with props and explicit id
228
+ const { result: firstResult } = renderHook(() =>
229
+ useBloc(SearchBloc, {
230
+ staticProps: { query: 'test' },
231
+ instanceId: 'search-instance',
232
+ }),
216
233
  );
217
234
 
218
- // Read-only consumer without props
219
- const { result: readerResult } = renderHook(() =>
220
- useBloc(SearchBloc, { props: { apiEndpoint: '/api/search' } })
235
+ // Second consumer without props but same instanceId
236
+ const { result: secondResult } = renderHook(() =>
237
+ useBloc(SearchBloc, { instanceId: 'search-instance' }),
221
238
  );
222
239
 
223
240
  await act(async () => {
224
- await new Promise(resolve => setTimeout(resolve, 0));
241
+ await new Promise((resolve) => setTimeout(resolve, 0));
225
242
  });
226
243
 
227
- // Both see the same state (but different object references due to proxy)
228
- expect(ownerResult.current[0]).toEqual(readerResult.current[0]);
229
- expect(readerResult.current[0].results).toEqual(['Search: test']);
244
+ // Same instance (check by ID)
245
+ expect(firstResult.current[1]._id).toBe('search-instance');
246
+ expect(secondResult.current[1]._id).toBe('search-instance');
247
+
248
+ // Both see the same state
249
+ expect(secondResult.current[0].results).toEqual(['Search: test']);
230
250
  });
231
251
 
232
- it('should transfer ownership when owner unmounts', async () => {
252
+ it('should create independent instances with different staticProps', async () => {
233
253
  const { result: result1, unmount: unmount1 } = renderHook(() =>
234
- useBloc(
235
- SearchBloc,
236
- { props: { apiEndpoint: '/api/search', query: 'first' } }
237
- )
254
+ useBloc(SearchBloc, { staticProps: { query: 'first' } }),
238
255
  );
239
256
 
240
257
  await act(async () => {
241
- await new Promise(resolve => setTimeout(resolve, 0));
258
+ await new Promise((resolve) => setTimeout(resolve, 0));
242
259
  });
243
260
 
244
261
  expect(result1.current[0].results).toEqual(['Search: first']);
262
+ const instance1Id = result1.current[1]._id;
245
263
 
246
- // Unmount first owner
247
- unmount1();
248
-
249
- // New owner can take over
264
+ // Create second instance with different props
250
265
  const { result: result2 } = renderHook(() =>
251
- useBloc(
252
- SearchBloc,
253
- { props: { apiEndpoint: '/api/search', query: 'second' } }
254
- )
266
+ useBloc(SearchBloc, { staticProps: { query: 'second' } }),
255
267
  );
256
268
 
257
269
  await act(async () => {
258
- await new Promise(resolve => setTimeout(resolve, 0));
270
+ await new Promise((resolve) => setTimeout(resolve, 0));
259
271
  });
260
272
 
273
+ expect(result2.current[0].results).toEqual(['Search: second']);
274
+ const instance2Id = result2.current[1]._id;
275
+
276
+ // Different instances with different auto-generated IDs
277
+ expect(instance1Id).not.toBe(instance2Id);
278
+ expect(result1.current[1]).not.toBe(result2.current[1]);
279
+
280
+ // Unmount first - second is unaffected
281
+ unmount1();
282
+
261
283
  expect(result2.current[0].results).toEqual(['Search: second']);
262
284
  });
263
285
  });
@@ -265,17 +287,14 @@ describe('useBloc props integration', () => {
265
287
  describe('Props with other options', () => {
266
288
  it('should work with key option', async () => {
267
289
  const { result } = renderHook(() =>
268
- useBloc(
269
- SearchBloc,
270
- {
271
- id: 'custom-search',
272
- props: { apiEndpoint: '/api/search', query: 'test' }
273
- }
274
- )
290
+ useBloc(SearchBloc, {
291
+ instanceId: 'custom-search',
292
+ staticProps: { query: 'test' },
293
+ }),
275
294
  );
276
295
 
277
296
  await act(async () => {
278
- await new Promise(resolve => setTimeout(resolve, 0));
297
+ await new Promise((resolve) => setTimeout(resolve, 0));
279
298
  });
280
299
 
281
300
  expect(result.current[0].results).toEqual(['Search: test']);
@@ -287,14 +306,11 @@ describe('useBloc props integration', () => {
287
306
  const onUnmount = vi.fn();
288
307
 
289
308
  const { result, unmount } = renderHook(() =>
290
- useBloc(
291
- SearchBloc,
292
- {
293
- props: { apiEndpoint: '/api/search', query: 'test' },
294
- onMount,
295
- onUnmount
296
- }
297
- )
309
+ useBloc(SearchBloc, {
310
+ staticProps: { query: 'test' },
311
+ onMount,
312
+ onUnmount,
313
+ }),
298
314
  );
299
315
 
300
316
  expect(onMount).toHaveBeenCalledWith(result.current[1]);
@@ -306,38 +322,43 @@ describe('useBloc props integration', () => {
306
322
 
307
323
  it('should work with manual dependencies', () => {
308
324
  const { result, rerender } = renderHook(
309
- ({ step }) => useBloc(
310
- CounterCubit,
311
- {
312
- props: { step },
313
- dependencies: (cubit) => [cubit.state.count]
314
- }
315
- ),
316
- { initialProps: { step: 1 } }
325
+ ({ step }) =>
326
+ useBloc(CounterCubit, {
327
+ staticProps: { step },
328
+ dependencies: (cubit) => [cubit.state.count],
329
+ }),
330
+ { initialProps: { step: 1 } },
317
331
  );
318
332
 
319
- const [, cubit] = result.current;
333
+ const [, cubit] = result.current as [CounterState, CounterCubit];
320
334
 
321
335
  // Increment should trigger re-render (count is a dependency)
322
336
  act(() => {
323
337
  cubit.increment();
324
338
  });
325
339
 
326
- expect(result.current[0].count).toBe(1);
340
+ expect((result.current[0] as CounterState).count).toBe(1);
327
341
 
328
- // Changing props should also work
342
+ // Changing props creates new instance
329
343
  rerender({ step: 2 });
330
- expect(result.current[0].stepSize).toBe(2);
344
+
345
+ // New instance has different state
346
+ const [newState, newCubit] = result.current as [
347
+ CounterState,
348
+ CounterCubit,
349
+ ];
350
+ expect(newCubit).not.toBe(cubit); // New instance
351
+ expect(newState.stepSize).toBe(2);
352
+ expect(newState.count).toBe(0); // New instance starts at 0
331
353
  });
332
354
  });
333
355
 
334
356
  describe('Edge cases', () => {
335
357
  it('should handle undefined props', async () => {
336
358
  const { result } = renderHook(() => {
337
- const [state, cubit] = useBloc(
338
- CounterCubit,
339
- { props: undefined }
340
- );
359
+ const [state, cubit] = useBloc(CounterCubit, {
360
+ staticProps: undefined,
361
+ });
341
362
  // Access count to ensure it's tracked
342
363
  void state.count;
343
364
  return [state, cubit];
@@ -345,10 +366,10 @@ describe('useBloc props integration', () => {
345
366
 
346
367
  // Wait for any effects
347
368
  await act(async () => {
348
- await new Promise(resolve => setTimeout(resolve, 0));
369
+ await new Promise((resolve) => setTimeout(resolve, 0));
349
370
  });
350
371
 
351
- const [state, cubit] = result.current;
372
+ const [state, cubit] = result.current as [CounterState, CounterCubit];
352
373
  expect(state.stepSize).toBe(1);
353
374
 
354
375
  act(() => {
@@ -357,7 +378,7 @@ describe('useBloc props integration', () => {
357
378
 
358
379
  // Wait for React to re-render with new state
359
380
  await waitFor(() => {
360
- expect(result.current[0].count).toBe(1); // Uses default step
381
+ expect((result.current[0] as CounterState).count).toBe(1); // Uses default step
361
382
  });
362
383
  });
363
384
 
@@ -367,12 +388,9 @@ describe('useBloc props integration', () => {
367
388
  const { rerender } = renderHook(
368
389
  ({ query }) => {
369
390
  renderCount++;
370
- return useBloc(
371
- SearchBloc,
372
- { props: { apiEndpoint: '/api/search', query } }
373
- );
391
+ return useBloc(SearchBloc, { staticProps: { query } });
374
392
  },
375
- { initialProps: { query: 'test' } }
393
+ { initialProps: { query: 'test' } },
376
394
  );
377
395
 
378
396
  const initialRenderCount = renderCount;
@@ -386,26 +404,30 @@ describe('useBloc props integration', () => {
386
404
 
387
405
  it('should handle rapid props updates', async () => {
388
406
  const { result, rerender } = renderHook(
389
- ({ query }) => useBloc(
390
- SearchBloc,
391
- { props: { apiEndpoint: '/api/search', query } }
392
- ),
393
- { initialProps: { query: 'initial' } }
407
+ ({ query }) => useBloc(SearchBloc, { staticProps: { query } }),
408
+ { initialProps: { query: 'initial' } },
394
409
  );
395
410
 
396
411
  // Wait for initial render
397
412
  await act(async () => {
398
- await new Promise(resolve => setTimeout(resolve, 0));
413
+ await new Promise((resolve) => setTimeout(resolve, 0));
399
414
  });
400
415
 
401
- // Rapid updates
416
+ const firstInstance = result.current[1];
417
+
418
+ // Rapid updates - each creates a new instance
402
419
  await act(async () => {
403
420
  rerender({ query: 'update1' });
404
421
  rerender({ query: 'update2' });
405
422
  rerender({ query: 'update3' });
406
- await new Promise(resolve => setTimeout(resolve, 50));
423
+ await new Promise((resolve) => setTimeout(resolve, 50));
407
424
  });
408
425
 
426
+ const lastInstance = result.current[1];
427
+
428
+ // Different instance after updates
429
+ expect(lastInstance).not.toBe(firstInstance);
430
+
409
431
  // Should have the latest value
410
432
  expect(result.current[0].results).toEqual(['Search: update3']);
411
433
  });
@@ -115,16 +115,16 @@ describe('useBloc with proxy tracking config', () => {
115
115
  it('should still respect manual dependencies when provided', () => {
116
116
  const renderSpy = vi.fn();
117
117
 
118
- const { result } = renderHook(() =>
118
+ const { result } = renderHook(() =>
119
119
  useBloc(TestCubit, {
120
- dependencies: (bloc) => [bloc.state.count]
121
- })
120
+ dependencies: (bloc) => [bloc.state.count],
121
+ }),
122
122
  );
123
123
 
124
124
  // Create another hook that tracks renders
125
125
  renderHook(() => {
126
126
  const [state] = useBloc(TestCubit, {
127
- dependencies: (bloc) => [bloc.state.count]
127
+ dependencies: (bloc) => [bloc.state.count],
128
128
  });
129
129
  renderSpy(state);
130
130
  return state;
@@ -225,4 +225,4 @@ describe('useBloc with proxy tracking config', () => {
225
225
  expect(renderSpy2).toHaveBeenCalledTimes(2);
226
226
  });
227
227
  });
228
- });
228
+ });