@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.
@@ -0,0 +1,257 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { renderHook, act } from '@testing-library/react';
3
+ import { Blac, Cubit, RerenderLogger } from '@blac/core';
4
+ import { RenderLoggingPlugin } from '@blac/plugin-render-logging';
5
+ import useBloc from '../useBloc';
6
+
7
+ class TestCubit extends Cubit<{ count: number; name: string }> {
8
+ constructor() {
9
+ super({ count: 0, name: 'test' });
10
+ }
11
+
12
+ increment = () => this.emit({ ...this.state, count: this.state.count + 1 });
13
+ changeName = (name: string) => this.emit({ ...this.state, name });
14
+ changeAll = () => this.emit({ count: this.state.count + 1, name: 'changed' });
15
+ }
16
+
17
+ describe('Rerender Logging', () => {
18
+ let consoleSpy: any;
19
+ let plugin: RenderLoggingPlugin;
20
+
21
+ beforeEach(() => {
22
+ Blac.resetInstance();
23
+ RerenderLogger.clear();
24
+ consoleSpy = {
25
+ log: vi.spyOn(console, 'log').mockImplementation(() => {}),
26
+ group: vi.spyOn(console, 'group').mockImplementation(() => {}),
27
+ groupEnd: vi.spyOn(console, 'groupEnd').mockImplementation(() => {}),
28
+ table: vi.spyOn(console, 'table').mockImplementation(() => {}),
29
+ };
30
+ });
31
+
32
+ afterEach(() => {
33
+ consoleSpy.log.mockRestore();
34
+ consoleSpy.group.mockRestore();
35
+ consoleSpy.groupEnd.mockRestore();
36
+ consoleSpy.table.mockRestore();
37
+ // Remove plugin if it was added
38
+ if (plugin) {
39
+ Blac.getInstance().plugins.remove(plugin.name);
40
+ }
41
+ });
42
+
43
+ it('should not log when rerender logging is disabled', () => {
44
+ // No plugin registered
45
+ const { result } = renderHook(() => useBloc(TestCubit));
46
+
47
+ act(() => {
48
+ result.current[1].increment();
49
+ });
50
+
51
+ expect(consoleSpy.log).not.toHaveBeenCalled();
52
+ });
53
+
54
+ it('should log rerenders when enabled with minimal level', () => {
55
+ plugin = new RenderLoggingPlugin({ enabled: true, level: 'minimal' });
56
+ Blac.getInstance().plugins.add(plugin);
57
+
58
+ const { result } = renderHook(() => useBloc(TestCubit));
59
+
60
+ // Clear logs from mount
61
+ consoleSpy.log.mockClear();
62
+
63
+ act(() => {
64
+ result.current[1].increment();
65
+ });
66
+
67
+ expect(consoleSpy.log).toHaveBeenCalled();
68
+ const logCall = consoleSpy.log.mock.calls[0][0];
69
+ expect(logCall).toContain('#2'); // Second render (first is mount)
70
+ });
71
+
72
+ it('should log state changes with normal level', () => {
73
+ plugin = new RenderLoggingPlugin({ enabled: true, level: 'normal' });
74
+ Blac.getInstance().plugins.add(plugin);
75
+
76
+ const { result } = renderHook(() => useBloc(TestCubit));
77
+
78
+ // Clear logs from mount
79
+ consoleSpy.log.mockClear();
80
+
81
+ act(() => {
82
+ result.current[1].increment();
83
+ });
84
+
85
+ expect(consoleSpy.log).toHaveBeenCalled();
86
+ const logCall = consoleSpy.log.mock.calls[0];
87
+ expect(logCall.join(' ')).toContain('State changed');
88
+ expect(logCall.join(' ')).toContain('(entire state)'); // Without proxy tracking individual properties
89
+ });
90
+
91
+ it('should log detailed information with detailed level', () => {
92
+ plugin = new RenderLoggingPlugin({ enabled: true, level: 'detailed' });
93
+ Blac.getInstance().plugins.add(plugin);
94
+
95
+ const { result } = renderHook(() => useBloc(TestCubit));
96
+
97
+ // Clear logs from mount
98
+ consoleSpy.log.mockClear();
99
+ consoleSpy.group.mockClear();
100
+ consoleSpy.table.mockClear();
101
+
102
+ act(() => {
103
+ result.current[1].increment();
104
+ });
105
+
106
+ expect(consoleSpy.group).toHaveBeenCalled();
107
+ expect(consoleSpy.table).toHaveBeenCalled();
108
+
109
+ const tableData = consoleSpy.table.mock.calls[0][0];
110
+ // Since we haven't accessed specific properties, it shows '(entire state)'
111
+ expect(tableData).toHaveProperty('(entire state)');
112
+ expect(tableData['(entire state)']).toHaveProperty('old');
113
+ expect(tableData['(entire state)']).toHaveProperty('new');
114
+ });
115
+
116
+ it('should track dependency changes when using manual dependencies', () => {
117
+ plugin = new RenderLoggingPlugin({ enabled: true, level: 'normal' });
118
+ Blac.getInstance().plugins.add(plugin);
119
+
120
+ let depValue = 1;
121
+ const { result, rerender } = renderHook(() =>
122
+ useBloc(TestCubit, {
123
+ dependencies: () => [depValue],
124
+ }),
125
+ );
126
+
127
+ // Clear logs from mount
128
+ consoleSpy.log.mockClear();
129
+
130
+ // Change dependency
131
+ depValue = 2;
132
+ rerender();
133
+
134
+ expect(consoleSpy.log).toHaveBeenCalled();
135
+ const logCall = consoleSpy.log.mock.calls[0];
136
+ const logMessage = logCall.join(' ');
137
+
138
+ // The plugin logs "Manual dependencies changed" in the description
139
+ expect(logMessage).toContain('Manual dependencies changed');
140
+ // Verify it's logged as a dependency change (check for the emoji)
141
+ expect(logMessage).toContain('🔗');
142
+ });
143
+
144
+ it('should respect filter function', () => {
145
+ plugin = new RenderLoggingPlugin({
146
+ enabled: true,
147
+ filter: ({ blocName }) => blocName !== 'TestCubit',
148
+ });
149
+ Blac.getInstance().plugins.add(plugin);
150
+
151
+ const { result } = renderHook(() => useBloc(TestCubit));
152
+
153
+ act(() => {
154
+ result.current[1].increment();
155
+ });
156
+
157
+ expect(consoleSpy.log).not.toHaveBeenCalled();
158
+ });
159
+
160
+ it('should group consecutive rerenders when groupRerenders is enabled', async () => {
161
+ plugin = new RenderLoggingPlugin({
162
+ enabled: true,
163
+ groupRerenders: true,
164
+ });
165
+ Blac.getInstance().plugins.add(plugin);
166
+
167
+ const { result } = renderHook(() => useBloc(TestCubit));
168
+
169
+ // Clear logs from mount
170
+ consoleSpy.log.mockClear();
171
+ consoleSpy.group.mockClear();
172
+
173
+ // Multiple rapid state changes - React may batch these
174
+ act(() => {
175
+ result.current[1].increment();
176
+ });
177
+
178
+ act(() => {
179
+ result.current[1].increment();
180
+ });
181
+
182
+ act(() => {
183
+ result.current[1].increment();
184
+ });
185
+
186
+ // Wait for debounce
187
+ await new Promise((resolve) => setTimeout(resolve, 100));
188
+
189
+ expect(consoleSpy.group).toHaveBeenCalled();
190
+ const groupCall = consoleSpy.group.mock.calls[0][0];
191
+ // Should have at least 2 renders (React may batch some updates)
192
+ expect(groupCall).toMatch(/rendered \d+ times/);
193
+ // Verify it grouped multiple renders
194
+ expect(
195
+ parseInt(groupCall.match(/rendered (\d+) times/)?.[1] || '0'),
196
+ ).toBeGreaterThanOrEqual(2);
197
+ });
198
+
199
+ it('should track multiple state properties changes', () => {
200
+ plugin = new RenderLoggingPlugin({ enabled: true, level: 'normal' });
201
+ Blac.getInstance().plugins.add(plugin);
202
+
203
+ const { result } = renderHook(() => useBloc(TestCubit));
204
+
205
+ // Clear logs from mount
206
+ consoleSpy.log.mockClear();
207
+
208
+ // Change all properties
209
+ act(() => {
210
+ result.current[1].changeAll();
211
+ });
212
+
213
+ expect(consoleSpy.log).toHaveBeenCalled();
214
+ const logCall = consoleSpy.log.mock.calls[0].join(' ');
215
+ expect(logCall).toContain('(entire state)'); // Without proxy tracking, all properties changed
216
+ });
217
+
218
+ it('should show mount reason for first render', () => {
219
+ plugin = new RenderLoggingPlugin({ enabled: true });
220
+ Blac.getInstance().plugins.add(plugin);
221
+
222
+ renderHook(() => useBloc(TestCubit));
223
+
224
+ expect(consoleSpy.log).toHaveBeenCalled();
225
+ const logCall = consoleSpy.log.mock.calls[0];
226
+ expect(logCall.join(' ')).toContain('mount');
227
+ expect(logCall.join(' ')).toContain('Component mounted');
228
+ });
229
+
230
+ it('should include stack trace in detailed mode when enabled', () => {
231
+ plugin = new RenderLoggingPlugin({
232
+ enabled: true,
233
+ level: 'detailed',
234
+ includeStackTrace: true,
235
+ });
236
+ Blac.getInstance().plugins.add(plugin);
237
+
238
+ const { result } = renderHook(() => useBloc(TestCubit));
239
+
240
+ // Clear logs from mount
241
+ consoleSpy.log.mockClear();
242
+ consoleSpy.group.mockClear();
243
+
244
+ act(() => {
245
+ result.current[1].increment();
246
+ });
247
+
248
+ // In detailed mode with stack trace, it logs the stack
249
+ const logCalls = consoleSpy.log.mock.calls;
250
+ const hasStackTrace = logCalls.some((call: any[]) =>
251
+ call.some(
252
+ (arg: any) => typeof arg === 'string' && arg.includes('Stack trace:'),
253
+ ),
254
+ );
255
+ expect(hasStackTrace).toBe(true);
256
+ });
257
+ });
@@ -0,0 +1,377 @@
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
+ });