@blac/react 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blac/react",
3
- "version": "2.0.0-rc-8",
3
+ "version": "2.0.0-rc-11",
4
4
  "license": "MIT",
5
5
  "author": "Brendan Mullins <jsnanigans@gmail.com>",
6
6
  "main": "dist/index.cjs.js",
@@ -30,7 +30,7 @@
30
30
  "@types/react-dom": "^18.0.0 || ^19.0.0",
31
31
  "react": "^18.0.0 || ^19.0.0",
32
32
  "react-dom": "^18.0.0 || ^19.0.0",
33
- "@blac/core": "2.0.0-rc-8"
33
+ "@blac/core": "2.0.0-rc-11"
34
34
  },
35
35
  "peerDependenciesMeta": {
36
36
  "@types/react": {
@@ -41,17 +41,17 @@
41
41
  }
42
42
  },
43
43
  "devDependencies": {
44
- "@testing-library/dom": "^10.4.0",
44
+ "@testing-library/dom": "^10.4.1",
45
45
  "@testing-library/jest-dom": "^6.6.3",
46
46
  "@testing-library/react": "^16.3.0",
47
47
  "@testing-library/user-event": "^14.6.1",
48
- "@types/react": "^19.1.4",
49
- "@vitejs/plugin-react": "^4.4.1",
50
- "@vitest/browser": "^3.1.3",
51
- "@vitest/coverage-v8": "^3.1.3",
52
- "happy-dom": "^17.4.7",
48
+ "@types/react": "^19.1.8",
49
+ "@vitejs/plugin-react": "^4.7.0",
50
+ "@vitest/browser": "^3.2.4",
51
+ "@vitest/coverage-v8": "^3.2.4",
52
+ "happy-dom": "^18.0.1",
53
53
  "jsdom": "^24.1.1",
54
- "prettier": "^3.5.3",
54
+ "prettier": "^3.6.2",
55
55
  "react": "^19.1.0",
56
56
  "react-dom": "^19.1.0",
57
57
  "typescript": "^5.8.3",
@@ -61,6 +61,7 @@
61
61
  },
62
62
  "scripts": {
63
63
  "prettier": "prettier --write ./src",
64
+ "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx}\"",
64
65
  "test": "vitest run --config vitest.config.ts",
65
66
  "test:watch": "vitest --watch --config vitest.config.ts",
66
67
  "typecheck": "tsc --noEmit",
@@ -0,0 +1,413 @@
1
+ import { Blac, Bloc, Cubit, PropsUpdated } from '@blac/core';
2
+ import { act, renderHook, waitFor } from '@testing-library/react';
3
+ import React from 'react';
4
+ import { describe, expect, it, vi, beforeEach } from 'vitest';
5
+ import useBloc from '../useBloc';
6
+
7
+ // Test components
8
+ interface SearchProps {
9
+ query: string;
10
+ limit?: number;
11
+ }
12
+
13
+ interface SearchState {
14
+ results: string[];
15
+ loading: boolean;
16
+ }
17
+
18
+ class SearchBloc extends Bloc<SearchState, PropsUpdated<SearchProps>> {
19
+ constructor(private config: { apiEndpoint: string }) {
20
+ super({ results: [], loading: false });
21
+
22
+ this.on(PropsUpdated<SearchProps>, (event, emit) => {
23
+ emit({ results: [`Search: ${event.props.query}`], loading: false });
24
+ });
25
+ }
26
+ }
27
+
28
+ interface CounterProps {
29
+ step: number;
30
+ }
31
+
32
+ interface CounterState {
33
+ count: number;
34
+ stepSize: number;
35
+ }
36
+
37
+ class CounterCubit extends Cubit<CounterState, CounterProps> {
38
+ constructor() {
39
+ super({ count: 0, stepSize: 1 });
40
+ }
41
+
42
+ protected onPropsChanged(oldProps: CounterProps | undefined, newProps: CounterProps): void {
43
+ if (oldProps?.step !== newProps.step) {
44
+ this.emit({ ...this.state, stepSize: newProps.step });
45
+ }
46
+ }
47
+
48
+ increment = () => {
49
+ const step = this.props?.step ?? 1;
50
+ this.emit({ ...this.state, count: this.state.count + step, stepSize: step });
51
+ };
52
+ }
53
+
54
+ describe('useBloc props integration', () => {
55
+ beforeEach(() => {
56
+ Blac.resetInstance();
57
+ });
58
+
59
+ describe('Basic props functionality', () => {
60
+ it('should pass props to Bloc via adapter', async () => {
61
+ const { result } = renderHook(
62
+ ({ query }) => useBloc(
63
+ SearchBloc,
64
+ { props: { apiEndpoint: '/api/search', query } }
65
+ ),
66
+ { initialProps: { query: 'initial' } }
67
+ );
68
+
69
+ await act(async () => {
70
+ await new Promise(resolve => setTimeout(resolve, 0));
71
+ });
72
+
73
+ const [state] = result.current;
74
+ expect(state.results).toEqual(['Search: initial']);
75
+ });
76
+
77
+ it('should update props when they change', async () => {
78
+ const { result, rerender } = renderHook(
79
+ ({ query }) => useBloc(
80
+ SearchBloc,
81
+ { props: { apiEndpoint: '/api/search', query } }
82
+ ),
83
+ { initialProps: { query: 'initial' } }
84
+ );
85
+
86
+ await act(async () => {
87
+ await new Promise(resolve => setTimeout(resolve, 0));
88
+ });
89
+
90
+ expect(result.current[0].results).toEqual(['Search: initial']);
91
+
92
+ rerender({ query: 'updated' });
93
+
94
+ await act(async () => {
95
+ await new Promise(resolve => setTimeout(resolve, 0));
96
+ });
97
+
98
+ expect(result.current[0].results).toEqual(['Search: updated']);
99
+ });
100
+
101
+ it('should work with Cubit props', async () => {
102
+ const { result } = renderHook(
103
+ ({ step }) => {
104
+ const [state, cubit] = useBloc(
105
+ CounterCubit,
106
+ { props: { step } }
107
+ );
108
+ // Access count to ensure it's tracked
109
+ void state.count;
110
+ return [state, cubit];
111
+ },
112
+ { initialProps: { step: 1 } }
113
+ );
114
+
115
+ // Wait for props to be set
116
+ await act(async () => {
117
+ await new Promise(resolve => setTimeout(resolve, 0));
118
+ });
119
+
120
+ const [state, cubit] = result.current;
121
+ expect(state.stepSize).toBe(1);
122
+
123
+ act(() => {
124
+ cubit.increment();
125
+ });
126
+
127
+ // Wait for React to re-render with new state
128
+ await waitFor(() => {
129
+ expect(result.current[0].count).toBe(1);
130
+ });
131
+ });
132
+
133
+ it('should update Cubit props reactively', async () => {
134
+ const { result, rerender } = renderHook(
135
+ ({ step }) => {
136
+ const [state, cubit] = useBloc(
137
+ CounterCubit,
138
+ { props: { step } }
139
+ );
140
+ // Access count to ensure it's tracked
141
+ void state.count;
142
+ return [state, cubit];
143
+ },
144
+ { initialProps: { step: 1 } }
145
+ );
146
+
147
+ // Wait for initial props to be set
148
+ await act(async () => {
149
+ await new Promise(resolve => setTimeout(resolve, 0));
150
+ });
151
+
152
+ expect(result.current[0].stepSize).toBe(1);
153
+
154
+ rerender({ step: 5 });
155
+
156
+ // Wait for props update
157
+ await act(async () => {
158
+ await new Promise(resolve => setTimeout(resolve, 0));
159
+ });
160
+
161
+ expect(result.current[0].stepSize).toBe(5);
162
+
163
+ act(() => {
164
+ result.current[1].increment();
165
+ });
166
+
167
+ // Wait for React to re-render with new state
168
+ await waitFor(() => {
169
+ expect(result.current[0].count).toBe(5);
170
+ });
171
+ });
172
+ });
173
+
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
179
+ const { result: result1 } = renderHook(() =>
180
+ useBloc(
181
+ SearchBloc,
182
+ { props: { apiEndpoint: '/api/search', query: 'owner' } }
183
+ )
184
+ );
185
+
186
+ // Second hook cannot override props
187
+ const { result: result2 } = renderHook(() =>
188
+ useBloc(
189
+ SearchBloc,
190
+ { props: { apiEndpoint: '/api/search', query: 'hijacker' } }
191
+ )
192
+ );
193
+
194
+ await act(async () => {
195
+ await new Promise(resolve => setTimeout(resolve, 0));
196
+ });
197
+
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']);
205
+
206
+ warnSpy.mockRestore();
207
+ });
208
+
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
+ )
216
+ );
217
+
218
+ // Read-only consumer without props
219
+ const { result: readerResult } = renderHook(() =>
220
+ useBloc(SearchBloc, { props: { apiEndpoint: '/api/search' } })
221
+ );
222
+
223
+ await act(async () => {
224
+ await new Promise(resolve => setTimeout(resolve, 0));
225
+ });
226
+
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']);
230
+ });
231
+
232
+ it('should transfer ownership when owner unmounts', async () => {
233
+ const { result: result1, unmount: unmount1 } = renderHook(() =>
234
+ useBloc(
235
+ SearchBloc,
236
+ { props: { apiEndpoint: '/api/search', query: 'first' } }
237
+ )
238
+ );
239
+
240
+ await act(async () => {
241
+ await new Promise(resolve => setTimeout(resolve, 0));
242
+ });
243
+
244
+ expect(result1.current[0].results).toEqual(['Search: first']);
245
+
246
+ // Unmount first owner
247
+ unmount1();
248
+
249
+ // New owner can take over
250
+ const { result: result2 } = renderHook(() =>
251
+ useBloc(
252
+ SearchBloc,
253
+ { props: { apiEndpoint: '/api/search', query: 'second' } }
254
+ )
255
+ );
256
+
257
+ await act(async () => {
258
+ await new Promise(resolve => setTimeout(resolve, 0));
259
+ });
260
+
261
+ expect(result2.current[0].results).toEqual(['Search: second']);
262
+ });
263
+ });
264
+
265
+ describe('Props with other options', () => {
266
+ it('should work with key option', async () => {
267
+ const { result } = renderHook(() =>
268
+ useBloc(
269
+ SearchBloc,
270
+ {
271
+ id: 'custom-search',
272
+ props: { apiEndpoint: '/api/search', query: 'test' }
273
+ }
274
+ )
275
+ );
276
+
277
+ await act(async () => {
278
+ await new Promise(resolve => setTimeout(resolve, 0));
279
+ });
280
+
281
+ expect(result.current[0].results).toEqual(['Search: test']);
282
+ expect(result.current[1]._id).toBe('custom-search');
283
+ });
284
+
285
+ it('should work with lifecycle hooks', async () => {
286
+ const onMount = vi.fn();
287
+ const onUnmount = vi.fn();
288
+
289
+ const { result, unmount } = renderHook(() =>
290
+ useBloc(
291
+ SearchBloc,
292
+ {
293
+ props: { apiEndpoint: '/api/search', query: 'test' },
294
+ onMount,
295
+ onUnmount
296
+ }
297
+ )
298
+ );
299
+
300
+ expect(onMount).toHaveBeenCalledWith(result.current[1]);
301
+
302
+ unmount();
303
+
304
+ expect(onUnmount).toHaveBeenCalledWith(result.current[1]);
305
+ });
306
+
307
+ it('should work with manual dependencies', () => {
308
+ 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 } }
317
+ );
318
+
319
+ const [, cubit] = result.current;
320
+
321
+ // Increment should trigger re-render (count is a dependency)
322
+ act(() => {
323
+ cubit.increment();
324
+ });
325
+
326
+ expect(result.current[0].count).toBe(1);
327
+
328
+ // Changing props should also work
329
+ rerender({ step: 2 });
330
+ expect(result.current[0].stepSize).toBe(2);
331
+ });
332
+ });
333
+
334
+ describe('Edge cases', () => {
335
+ it('should handle undefined props', async () => {
336
+ const { result } = renderHook(() => {
337
+ const [state, cubit] = useBloc(
338
+ CounterCubit,
339
+ { props: undefined }
340
+ );
341
+ // Access count to ensure it's tracked
342
+ void state.count;
343
+ return [state, cubit];
344
+ });
345
+
346
+ // Wait for any effects
347
+ await act(async () => {
348
+ await new Promise(resolve => setTimeout(resolve, 0));
349
+ });
350
+
351
+ const [state, cubit] = result.current;
352
+ expect(state.stepSize).toBe(1);
353
+
354
+ act(() => {
355
+ cubit.increment();
356
+ });
357
+
358
+ // Wait for React to re-render with new state
359
+ await waitFor(() => {
360
+ expect(result.current[0].count).toBe(1); // Uses default step
361
+ });
362
+ });
363
+
364
+ it('should not re-render for unchanged props', () => {
365
+ let renderCount = 0;
366
+
367
+ const { rerender } = renderHook(
368
+ ({ query }) => {
369
+ renderCount++;
370
+ return useBloc(
371
+ SearchBloc,
372
+ { props: { apiEndpoint: '/api/search', query } }
373
+ );
374
+ },
375
+ { initialProps: { query: 'test' } }
376
+ );
377
+
378
+ const initialRenderCount = renderCount;
379
+
380
+ // Rerender with same props
381
+ rerender({ query: 'test' });
382
+
383
+ // Should not cause additional renders beyond React's normal behavior
384
+ expect(renderCount).toBe(initialRenderCount + 1);
385
+ });
386
+
387
+ it('should handle rapid props updates', async () => {
388
+ const { result, rerender } = renderHook(
389
+ ({ query }) => useBloc(
390
+ SearchBloc,
391
+ { props: { apiEndpoint: '/api/search', query } }
392
+ ),
393
+ { initialProps: { query: 'initial' } }
394
+ );
395
+
396
+ // Wait for initial render
397
+ await act(async () => {
398
+ await new Promise(resolve => setTimeout(resolve, 0));
399
+ });
400
+
401
+ // Rapid updates
402
+ await act(async () => {
403
+ rerender({ query: 'update1' });
404
+ rerender({ query: 'update2' });
405
+ rerender({ query: 'update3' });
406
+ await new Promise(resolve => setTimeout(resolve, 50));
407
+ });
408
+
409
+ // Should have the latest value
410
+ expect(result.current[0].results).toEqual(['Search: update3']);
411
+ });
412
+ });
413
+ });
@@ -0,0 +1,228 @@
1
+ import { Blac, Cubit } from '@blac/core';
2
+ import { renderHook, act } from '@testing-library/react';
3
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
4
+ import useBloc from '../useBloc';
5
+
6
+ interface TestState {
7
+ count: number;
8
+ nested: {
9
+ value: string;
10
+ };
11
+ }
12
+
13
+ class TestCubit extends Cubit<TestState> {
14
+ constructor() {
15
+ super({ count: 0, nested: { value: 'initial' } });
16
+ }
17
+
18
+ increment = () => {
19
+ this.emit({ ...this.state, count: this.state.count + 1 });
20
+ };
21
+
22
+ updateNested = () => {
23
+ this.emit({ ...this.state, nested: { value: 'updated' } });
24
+ };
25
+ }
26
+
27
+ describe('useBloc with proxy tracking config', () => {
28
+ const originalConfig = { ...Blac.config };
29
+
30
+ beforeEach(() => {
31
+ Blac.resetInstance();
32
+ });
33
+
34
+ afterEach(() => {
35
+ Blac.setConfig(originalConfig);
36
+ Blac.resetInstance();
37
+ });
38
+
39
+ describe('when proxyDependencyTracking is enabled (default)', () => {
40
+ beforeEach(() => {
41
+ Blac.setConfig({ proxyDependencyTracking: true });
42
+ });
43
+
44
+ it('should only re-render when accessed properties change', () => {
45
+ const { result } = renderHook(() => useBloc(TestCubit));
46
+ const renderSpy = vi.fn();
47
+
48
+ // Access only count in render
49
+ renderHook(() => {
50
+ const [state] = useBloc(TestCubit);
51
+ renderSpy(state.count);
52
+ return state.count;
53
+ });
54
+
55
+ expect(renderSpy).toHaveBeenCalledTimes(1);
56
+ expect(renderSpy).toHaveBeenCalledWith(0);
57
+
58
+ // Update nested value (not accessed)
59
+ act(() => {
60
+ result.current[1].updateNested();
61
+ });
62
+
63
+ // Should not re-render since nested.value wasn't accessed
64
+ expect(renderSpy).toHaveBeenCalledTimes(1);
65
+
66
+ // Update count (accessed property)
67
+ act(() => {
68
+ result.current[1].increment();
69
+ });
70
+
71
+ // Should re-render since count was accessed
72
+ expect(renderSpy).toHaveBeenCalledTimes(2);
73
+ expect(renderSpy).toHaveBeenLastCalledWith(1);
74
+ });
75
+ });
76
+
77
+ describe('when proxyDependencyTracking is disabled', () => {
78
+ beforeEach(() => {
79
+ Blac.setConfig({ proxyDependencyTracking: false });
80
+ });
81
+
82
+ it('should re-render on any state change', () => {
83
+ const { result } = renderHook(() => useBloc(TestCubit));
84
+ const renderSpy = vi.fn();
85
+
86
+ // Access only count in render
87
+ renderHook(() => {
88
+ const [state] = useBloc(TestCubit);
89
+ renderSpy(state.count);
90
+ return state.count;
91
+ });
92
+
93
+ expect(renderSpy).toHaveBeenCalledTimes(1);
94
+ expect(renderSpy).toHaveBeenCalledWith(0);
95
+
96
+ // Update nested value (not accessed)
97
+ act(() => {
98
+ result.current[1].updateNested();
99
+ });
100
+
101
+ // Should re-render even though nested.value wasn't accessed
102
+ expect(renderSpy).toHaveBeenCalledTimes(2);
103
+ expect(renderSpy).toHaveBeenLastCalledWith(0); // count didn't change
104
+
105
+ // Update count
106
+ act(() => {
107
+ result.current[1].increment();
108
+ });
109
+
110
+ // Should re-render
111
+ expect(renderSpy).toHaveBeenCalledTimes(3);
112
+ expect(renderSpy).toHaveBeenLastCalledWith(1);
113
+ });
114
+
115
+ it('should still respect manual dependencies when provided', () => {
116
+ const renderSpy = vi.fn();
117
+
118
+ const { result } = renderHook(() =>
119
+ useBloc(TestCubit, {
120
+ dependencies: (bloc) => [bloc.state.count]
121
+ })
122
+ );
123
+
124
+ // Create another hook that tracks renders
125
+ renderHook(() => {
126
+ const [state] = useBloc(TestCubit, {
127
+ dependencies: (bloc) => [bloc.state.count]
128
+ });
129
+ renderSpy(state);
130
+ return state;
131
+ });
132
+
133
+ expect(renderSpy).toHaveBeenCalledTimes(1);
134
+
135
+ // Update nested value
136
+ act(() => {
137
+ result.current[1].updateNested();
138
+ });
139
+
140
+ // Should not re-render since dependencies only include count
141
+ expect(renderSpy).toHaveBeenCalledTimes(1);
142
+
143
+ // Update count
144
+ act(() => {
145
+ result.current[1].increment();
146
+ });
147
+
148
+ // Should re-render since count is in dependencies
149
+ expect(renderSpy).toHaveBeenCalledTimes(2);
150
+ });
151
+ });
152
+
153
+ describe('runtime config changes', () => {
154
+ it('should respect config changes for new hooks', () => {
155
+ // Start with proxy tracking enabled
156
+ Blac.setConfig({ proxyDependencyTracking: true });
157
+
158
+ // Create a new cubit instance for this test to avoid interference
159
+ class TestCubit2 extends Cubit<TestState> {
160
+ constructor() {
161
+ super({ count: 0, nested: { value: 'initial' } });
162
+ }
163
+
164
+ increment = () => {
165
+ this.emit({ ...this.state, count: this.state.count + 1 });
166
+ };
167
+
168
+ updateNested = () => {
169
+ this.emit({ ...this.state, nested: { value: 'updated' } });
170
+ };
171
+ }
172
+
173
+ const renderSpy1 = vi.fn();
174
+
175
+ const { result: result1 } = renderHook(() => {
176
+ const [state, bloc] = useBloc(TestCubit2);
177
+ renderSpy1(state.count);
178
+ return [state, bloc] as const;
179
+ });
180
+
181
+ expect(renderSpy1).toHaveBeenCalledTimes(1);
182
+
183
+ // Update nested value
184
+ act(() => {
185
+ result1.current[1].updateNested();
186
+ });
187
+
188
+ // Should not re-render since nested.value wasn't accessed (proxy tracking enabled)
189
+ expect(renderSpy1).toHaveBeenCalledTimes(1);
190
+
191
+ // Change config for future hooks
192
+ Blac.setConfig({ proxyDependencyTracking: false });
193
+
194
+ // Create new cubit class to ensure fresh instance
195
+ class TestCubit3 extends Cubit<TestState> {
196
+ constructor() {
197
+ super({ count: 0, nested: { value: 'initial' } });
198
+ }
199
+
200
+ increment = () => {
201
+ this.emit({ ...this.state, count: this.state.count + 1 });
202
+ };
203
+
204
+ updateNested = () => {
205
+ this.emit({ ...this.state, nested: { value: 'updated' } });
206
+ };
207
+ }
208
+
209
+ // Create new hook after config change
210
+ const renderSpy2 = vi.fn();
211
+ const { result: result2 } = renderHook(() => {
212
+ const [state, bloc] = useBloc(TestCubit3);
213
+ renderSpy2(state.count);
214
+ return [state, bloc] as const;
215
+ });
216
+
217
+ expect(renderSpy2).toHaveBeenCalledTimes(1);
218
+
219
+ // Update nested value on the new cubit
220
+ act(() => {
221
+ result2.current[1].updateNested();
222
+ });
223
+
224
+ // Should re-render even though nested.value wasn't accessed (proxy tracking disabled)
225
+ expect(renderSpy2).toHaveBeenCalledTimes(2);
226
+ });
227
+ });
228
+ });