@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.
- package/dist/index.cjs.js +2 -1
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +6 -5
- package/dist/index.es.js +105 -75
- package/dist/index.es.js.map +1 -1
- package/package.json +12 -12
- package/src/__tests__/rerenderLogging.test.tsx +257 -0
- package/src/__tests__/useBloc.disposal.test.tsx +377 -0
- package/src/__tests__/useBloc.instanceChange.test.tsx +200 -0
- package/src/__tests__/useBloc.props.test.tsx +166 -144
- package/src/__tests__/useBloc.proxyConfig.test.tsx +5 -5
- package/src/__tests__/useBloc.staticProps.test.tsx +297 -0
- package/src/__tests__/useBloc.tracking.test.tsx +7 -3
- package/src/index.ts +1 -0
- package/src/useBloc.ts +108 -33
- package/src/useExternalBlocStore.ts +14 -9
- package/README.md +0 -265
|
@@ -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
|
+
});
|