@blac/react 2.0.0-rc-16 → 2.0.0-rc.1

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,377 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import { render, screen, waitFor } from '@testing-library/react';
3
- import { act } from '@testing-library/react';
4
- import { Cubit, Blac } from '@blac/core';
5
- import useBloc from '../useBloc';
6
- import React from 'react';
7
-
8
- interface TestState {
9
- counter: number;
10
- text: string;
11
- }
12
-
13
- class SharedTestCubit extends Cubit<TestState> {
14
- // Not isolated - should be shared across components
15
- constructor() {
16
- super({ counter: 0, text: 'initial' });
17
- console.log('[SharedTestCubit] Created');
18
- }
19
-
20
- increment = () => {
21
- console.log(
22
- '[SharedTestCubit] increment called, current state:',
23
- this.state,
24
- );
25
- console.log(
26
- '[SharedTestCubit] disposal state on increment:',
27
- (this as any)._disposalState,
28
- );
29
- this.emit({ ...this.state, counter: this.state.counter + 1 });
30
- };
31
-
32
- updateText = (text: string) => {
33
- console.log(
34
- '[SharedTestCubit] updateText called with:',
35
- text,
36
- 'current state:',
37
- this.state,
38
- );
39
- console.log(
40
- '[SharedTestCubit] disposal state:',
41
- (this as any)._disposalState,
42
- );
43
- this.emit({ ...this.state, text });
44
- };
45
-
46
- async dispose() {
47
- console.log('[SharedTestCubit] Disposing');
48
- await super.dispose();
49
- }
50
- }
51
-
52
- describe('useBloc disposal issues', () => {
53
- afterEach(() => {
54
- // Clean up any existing blocs after each test
55
- const existingBloc = Blac.getBloc(SharedTestCubit);
56
- if (existingBloc) {
57
- existingBloc.dispose();
58
- }
59
- });
60
-
61
- it('should not dispose shared bloc when switching between components', async () => {
62
- // Track disposal errors
63
- const disposalErrors: string[] = [];
64
- const originalError = console.error;
65
- console.error = (...args: any[]) => {
66
- const message = args[0];
67
- if (
68
- typeof message === 'string' &&
69
- message.includes('Attempted state update')
70
- ) {
71
- disposalErrors.push(message);
72
- }
73
- originalError(...args);
74
- };
75
- // Component A uses the shared bloc
76
- const ComponentA = () => {
77
- const [state, cubit] = useBloc(SharedTestCubit);
78
- return (
79
- <div>
80
- <span data-testid="component-a-counter">{state.counter}</span>
81
- <button onClick={cubit.increment}>Increment A</button>
82
- </div>
83
- );
84
- };
85
-
86
- // Component B uses the same shared bloc with dependencies
87
- const ComponentB = () => {
88
- const [state, cubit] = useBloc(SharedTestCubit, {
89
- dependencies: (bloc) => [bloc.state.text],
90
- });
91
- return (
92
- <div>
93
- <span data-testid="component-b-text">{state.text}</span>
94
- <span data-testid="component-b-counter">{state.counter}</span>
95
- <button onClick={() => cubit.updateText('updated')}>
96
- Update Text B
97
- </button>
98
- </div>
99
- );
100
- };
101
-
102
- // App component that switches between A and B
103
- const App = () => {
104
- const [showA, setShowA] = React.useState(true);
105
- return (
106
- <div>
107
- <button onClick={() => setShowA(!showA)}>Toggle Component</button>
108
- {showA ? <ComponentA /> : <ComponentB />}
109
- </div>
110
- );
111
- };
112
-
113
- const { rerender } = render(<App />);
114
-
115
- // Initially showing Component A
116
- expect(screen.getByTestId('component-a-counter')).toHaveTextContent('0');
117
-
118
- // Increment counter in Component A
119
- act(() => {
120
- screen.getByText('Increment A').click();
121
- });
122
-
123
- // Wait for state to update
124
- await waitFor(() => {
125
- expect(screen.getByTestId('component-a-counter')).toHaveTextContent('1');
126
- });
127
-
128
- // Switch to Component B
129
- act(() => {
130
- screen.getByText('Toggle Component').click();
131
- });
132
-
133
- // Wait a bit to ensure Component B is mounted
134
- await act(async () => {
135
- await new Promise((resolve) => setTimeout(resolve, 20));
136
- });
137
-
138
- // Component B should see the updated counter from Component A
139
- await waitFor(() => {
140
- expect(screen.getByTestId('component-b-counter')).toHaveTextContent('1');
141
- expect(screen.getByTestId('component-b-text')).toHaveTextContent(
142
- 'initial',
143
- );
144
- });
145
-
146
- // Try to update text in Component B
147
- act(() => {
148
- screen.getByText('Update Text B').click();
149
- });
150
-
151
- // Wait a moment to see if state updates
152
- await act(async () => {
153
- await new Promise((resolve) => setTimeout(resolve, 50));
154
- });
155
-
156
- // Check the actual text value
157
- const textElement = screen.getByTestId('component-b-text');
158
- const actualText = textElement.textContent;
159
- console.log('Text after update attempt:', actualText);
160
-
161
- // Should successfully update without disposal errors
162
- expect(actualText).toBe('updated');
163
-
164
- // Check if any disposal errors were logged
165
- if (disposalErrors.length > 0) {
166
- console.log('Disposal errors found:', disposalErrors);
167
- }
168
- expect(disposalErrors).toHaveLength(0);
169
-
170
- // Switch back to Component A
171
- act(() => {
172
- screen.getByText('Toggle Component').click();
173
- });
174
-
175
- // Component A should still see the counter as 1
176
- await waitFor(() => {
177
- expect(screen.getByTestId('component-a-counter')).toHaveTextContent('1');
178
- });
179
-
180
- // Restore console.error
181
- console.error = originalError;
182
-
183
- // Cleanup handled by afterEach
184
- });
185
-
186
- it('should handle rapid component switching without disposal issues', async () => {
187
- let disposalErrorLogged = false;
188
- const originalError = console.error;
189
- console.error = (message: string) => {
190
- if (message.includes('Attempted state update on disposed bloc')) {
191
- disposalErrorLogged = true;
192
- }
193
- originalError(message);
194
- };
195
-
196
- const ComponentWithTimer = () => {
197
- const [state, cubit] = useBloc(SharedTestCubit);
198
-
199
- React.useEffect(() => {
200
- // Simulate async operation that might complete after unmount
201
- const timer = setTimeout(() => {
202
- cubit.increment();
203
- }, 50);
204
-
205
- return () => clearTimeout(timer);
206
- }, [cubit]);
207
-
208
- return <span data-testid="timer-counter">{state.counter}</span>;
209
- };
210
-
211
- const App = () => {
212
- const [show, setShow] = React.useState(true);
213
- return (
214
- <div>
215
- <button onClick={() => setShow(!show)}>Toggle</button>
216
- {show && <ComponentWithTimer />}
217
- </div>
218
- );
219
- };
220
-
221
- render(<App />);
222
-
223
- // Rapidly toggle the component
224
- act(() => {
225
- screen.getByText('Toggle').click(); // Hide
226
- });
227
-
228
- await act(async () => {
229
- await new Promise((resolve) => setTimeout(resolve, 10));
230
- screen.getByText('Toggle').click(); // Show again
231
- });
232
-
233
- // Wait for potential timer to fire
234
- await act(async () => {
235
- await new Promise((resolve) => setTimeout(resolve, 100));
236
- });
237
-
238
- // Should not have logged disposal error
239
- expect(disposalErrorLogged).toBe(false);
240
-
241
- // Restore console.error
242
- console.error = originalError;
243
-
244
- // Cleanup handled by afterEach
245
- });
246
-
247
- it('should not dispose bloc when switching quickly between components', async () => {
248
- // Use a unique instance ID for this test to avoid state contamination
249
- const testInstanceId = 'test-demo-app-issue';
250
-
251
- const errorMessages: string[] = [];
252
- const originalError = console.error;
253
- console.error = (message: string, ...args: any[]) => {
254
- if (
255
- typeof message === 'string' &&
256
- message.includes('Attempted state update')
257
- ) {
258
- errorMessages.push(message);
259
- }
260
- originalError(message, ...args);
261
- };
262
-
263
- // Component using automatic proxy tracking (like DependencyTrackingDemo)
264
- const ProxyTrackingComponent = () => {
265
- const [state, cubit] = useBloc(SharedTestCubit, {
266
- instanceId: testInstanceId,
267
- });
268
- // Access only counter, not text
269
- return (
270
- <div>
271
- <span data-testid="proxy-counter">{state.counter}</span>
272
- <button onClick={cubit.increment}>Increment</button>
273
- </div>
274
- );
275
- };
276
-
277
- // Component using manual dependencies (like CustomSelectorDemo)
278
- const ManualDependencyComponent = () => {
279
- const [state, cubit] = useBloc(SharedTestCubit, {
280
- instanceId: testInstanceId,
281
- dependencies: (bloc) => [bloc.state.text],
282
- });
283
- return (
284
- <div>
285
- <span data-testid="manual-text">{state.text}</span>
286
- <span data-testid="manual-counter">{state.counter}</span>
287
- <button onClick={() => cubit.updateText('updated')}>
288
- Update Text
289
- </button>
290
- </div>
291
- );
292
- };
293
-
294
- // Simulate the demo app behavior
295
- const DemoApp = () => {
296
- const [showProxy, setShowProxy] = React.useState(false);
297
- const [showManual, setShowManual] = React.useState(false);
298
-
299
- return (
300
- <div>
301
- <button onClick={() => setShowProxy(!showProxy)}>
302
- Toggle Proxy Tracking
303
- </button>
304
- <button onClick={() => setShowManual(!showManual)}>
305
- Toggle Manual Dependencies
306
- </button>
307
- {showProxy && <ProxyTrackingComponent />}
308
- {showManual && <ManualDependencyComponent />}
309
- </div>
310
- );
311
- };
312
-
313
- render(<DemoApp />);
314
-
315
- // Open proxy tracking component
316
- act(() => {
317
- screen.getByText('Toggle Proxy Tracking').click();
318
- });
319
-
320
- expect(screen.getByTestId('proxy-counter')).toHaveTextContent('0');
321
-
322
- // Increment counter
323
- act(() => {
324
- screen.getByText('Increment').click();
325
- });
326
-
327
- await waitFor(() => {
328
- expect(screen.getByTestId('proxy-counter')).toHaveTextContent('1');
329
- });
330
-
331
- // Close proxy tracking component
332
- act(() => {
333
- screen.getByText('Toggle Proxy Tracking').click();
334
- });
335
-
336
- // Switch quickly without waiting - disposal should be cancelled
337
- // Don't wait here - switch immediately
338
-
339
- // Open manual dependency component
340
- act(() => {
341
- screen.getByText('Toggle Manual Dependencies').click();
342
- });
343
-
344
- // Should see the incremented state because disposal was cancelled
345
- expect(screen.getByTestId('manual-counter')).toHaveTextContent('1');
346
- expect(screen.getByTestId('manual-text')).toHaveTextContent('initial');
347
-
348
- // Try to update text
349
- act(() => {
350
- screen.getByText('Update Text').click();
351
- });
352
-
353
- // Wait for state update
354
- await waitFor(() => {
355
- expect(screen.getByTestId('manual-text')).toHaveTextContent('updated');
356
- });
357
-
358
- // Check for disposal errors
359
- const disposalErrors = errorMessages.filter(
360
- (msg) =>
361
- msg.includes('Attempted state update on disposed bloc') ||
362
- msg.includes('Attempted state update on disposal_requested bloc'),
363
- );
364
-
365
- expect(disposalErrors).toHaveLength(0);
366
- expect(screen.getByTestId('manual-text')).toHaveTextContent('updated');
367
-
368
- // Restore console.error
369
- console.error = originalError;
370
-
371
- // Clean up test instance
372
- const testBloc = Blac.getBloc(SharedTestCubit, { id: testInstanceId });
373
- if (testBloc) {
374
- await testBloc.dispose();
375
- }
376
- });
377
- });
@@ -1,200 +0,0 @@
1
- import { act, renderHook } from '@testing-library/react';
2
- import { describe, expect, it, vi } from 'vitest';
3
- import { BlocConstructorParams, Cubit } from '@blac/core';
4
- import useBloc from '../useBloc';
5
-
6
- interface CounterProps {
7
- initialValue: number;
8
- step?: number;
9
- }
10
-
11
- class CounterCubit extends Cubit<number> {
12
- props: BlocConstructorParams<typeof CounterCubit>;
13
-
14
- constructor(props?: CounterProps) {
15
- super(props?.initialValue ?? 0);
16
- this.props = props;
17
- }
18
-
19
- increment = () => {
20
- const step = this.props?.step ?? 1;
21
- this.emit(this.state + step);
22
- };
23
- }
24
-
25
- describe('useBloc instance changes', () => {
26
- describe('when instanceId changes', () => {
27
- it('should use a different bloc instance', async () => {
28
- const { result, rerender } = renderHook(
29
- ({ instanceId }) =>
30
- useBloc(CounterCubit, {
31
- instanceId,
32
- staticProps: { initialValue: 0 },
33
- }),
34
- { initialProps: { instanceId: 'counter-1' } },
35
- );
36
-
37
- // Wait for initial render
38
- await act(async () => {
39
- await new Promise((resolve) => setTimeout(resolve, 0));
40
- });
41
-
42
- const firstInstance = result.current[1];
43
- expect(firstInstance._id).toBe('counter-1');
44
- expect(firstInstance.props).toEqual({ initialValue: 0 });
45
-
46
- // Change instanceId
47
- rerender({ instanceId: 'counter-2' });
48
-
49
- const secondInstance = result.current[1];
50
- expect(secondInstance._id).toBe('counter-2');
51
- expect(secondInstance).not.toBe(firstInstance);
52
- expect(result.current[0]).toBe(0); // New instance starts at 0
53
- });
54
- });
55
-
56
- describe('when staticProps change', () => {
57
- it('should use a different bloc instance when staticProps change', async () => {
58
- const { result, rerender } = renderHook(
59
- ({ initialValue }) =>
60
- useBloc(CounterCubit, {
61
- staticProps: { initialValue },
62
- }),
63
- { initialProps: { initialValue: 10 } },
64
- );
65
-
66
- // Wait for initial render
67
- await act(async () => {
68
- await new Promise((resolve) => setTimeout(resolve, 0));
69
- });
70
-
71
- const firstInstance = result.current[1];
72
- expect(result.current[0]).toBe(10);
73
- expect(firstInstance._id).toBe('initialValue:10');
74
-
75
- // Change staticProps
76
- rerender({ initialValue: 20 });
77
-
78
- const secondInstance = result.current[1];
79
- expect(secondInstance).not.toBe(firstInstance);
80
- expect(result.current[0]).toBe(20); // New instance with new initial value
81
- expect(secondInstance._id).toBe('initialValue:20');
82
- });
83
-
84
- it('should use same instance when non-primitive staticProps values change', () => {
85
- const { result, rerender } = renderHook(
86
- ({ config }) =>
87
- useBloc(CounterCubit, {
88
- staticProps: {
89
- initialValue: 5,
90
- config, // This will be ignored for instance ID
91
- } as any,
92
- }),
93
- { initialProps: { config: { debug: true } } },
94
- );
95
-
96
- const firstInstance = result.current[1];
97
- expect(firstInstance._id).toBe('initialValue:5');
98
-
99
- // Change non-primitive prop
100
- rerender({ config: { debug: false } });
101
-
102
- const secondInstance = result.current[1];
103
- expect(secondInstance).toBe(firstInstance); // Same instance
104
- expect(secondInstance._id).toBe('initialValue:5');
105
- });
106
-
107
- it('should change instance when primitive staticProps change', () => {
108
- const { result, rerender } = renderHook(
109
- ({ step }) =>
110
- useBloc(CounterCubit, {
111
- staticProps: { initialValue: 0, step },
112
- }),
113
- { initialProps: { step: 1 } },
114
- );
115
-
116
- const firstInstance = result.current[1];
117
- expect(firstInstance._id).toBe('initialValue:0|step:1');
118
-
119
- // Change primitive prop
120
- rerender({ step: 2 });
121
-
122
- const secondInstance = result.current[1];
123
- expect(secondInstance).not.toBe(firstInstance); // Different instance
124
- expect(secondInstance._id).toBe('initialValue:0|step:2');
125
- });
126
- });
127
-
128
- describe('when using both instanceId and staticProps', () => {
129
- it('should use explicit instanceId over generated one', () => {
130
- const { result } = renderHook(() =>
131
- useBloc(CounterCubit, {
132
- instanceId: 'my-counter',
133
- staticProps: { initialValue: 100 },
134
- }),
135
- );
136
-
137
- expect(result.current[1]._id).toBe('my-counter');
138
- expect(result.current[0]).toBe(100);
139
- });
140
-
141
- it('should change instance when instanceId changes even if staticProps are same', () => {
142
- const { result, rerender } = renderHook(
143
- ({ instanceId }) =>
144
- useBloc(CounterCubit, {
145
- instanceId,
146
- staticProps: { initialValue: 50 },
147
- }),
148
- { initialProps: { instanceId: 'counter-a' } },
149
- );
150
-
151
- const firstInstance = result.current[1];
152
-
153
- // Change instanceId
154
- rerender({ instanceId: 'counter-b' });
155
-
156
- const secondInstance = result.current[1];
157
- expect(secondInstance).not.toBe(firstInstance);
158
- expect(secondInstance._id).toBe('counter-b');
159
- expect(result.current[0]).toBe(50); // Same initial value, different instance
160
- });
161
- });
162
-
163
- describe('lifecycle hooks', () => {
164
- it('should call onMount/onUnmount when instance changes', () => {
165
- const onMount1 = vi.fn();
166
- const onUnmount1 = vi.fn();
167
- const onMount2 = vi.fn();
168
- const onUnmount2 = vi.fn();
169
-
170
- const { rerender, unmount } = renderHook(
171
- ({ instanceId, onMount, onUnmount }) =>
172
- useBloc(CounterCubit, { instanceId, onMount, onUnmount }),
173
- {
174
- initialProps: {
175
- instanceId: 'counter-1',
176
- onMount: onMount1,
177
- onUnmount: onUnmount1,
178
- },
179
- },
180
- );
181
-
182
- expect(onMount1).toHaveBeenCalledTimes(1);
183
- expect(onUnmount1).not.toHaveBeenCalled();
184
-
185
- // Change instance
186
- rerender({
187
- instanceId: 'counter-2',
188
- onMount: onMount2,
189
- onUnmount: onUnmount2,
190
- });
191
-
192
- expect(onUnmount1).toHaveBeenCalledTimes(1);
193
- expect(onMount2).toHaveBeenCalledTimes(1);
194
- expect(onUnmount2).not.toHaveBeenCalled();
195
-
196
- unmount();
197
- expect(onUnmount2).toHaveBeenCalledTimes(1);
198
- });
199
- });
200
- });