-
Notifications
You must be signed in to change notification settings - Fork 2.9k
Expand file tree
/
Copy pathuseContextSelector.test.tsx
More file actions
201 lines (164 loc) · 7.04 KB
/
useContextSelector.test.tsx
File metadata and controls
201 lines (164 loc) · 7.04 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
import { createContext } from './createContext';
import { useContextSelector } from './useContextSelector';
import { render, act } from '@testing-library/react';
import * as React from 'react';
const TestContext = createContext<{ index: number }>({ index: -1 });
const TestComponent: React.FC<{ index: number; onUpdate?: () => void }> = props => {
const active = useContextSelector(TestContext, v => v.index === props.index);
React.useEffect(() => {
props.onUpdate && props.onUpdate();
});
return <div className="test-component" data-active={active} />;
};
const TestProvider: React.FC<{ children?: React.ReactNode }> = props => {
const [index, setIndex] = React.useState<number>(0);
return (
<div className="test-provider" onClick={() => setIndex(prevIndex => prevIndex + 1)}>
<TestContext.Provider value={{ index }}>{props.children}</TestContext.Provider>
</div>
);
};
describe('useContextSelector', () => {
let container: HTMLElement | null;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
});
afterEach(() => {
document.body.removeChild(container as HTMLElement);
container = null;
});
it('updates only on selector match', () => {
const onUpdate = jest.fn();
render(
<TestProvider>
<TestComponent index={1} onUpdate={onUpdate} />
</TestProvider>,
{ container: container as HTMLElement },
);
act(() => {
// no-op to wait for effects
});
expect(document.querySelector<HTMLElement>('.test-component')?.dataset.active).toBe('false');
expect(onUpdate).toHaveBeenCalledTimes(1);
// Match => update, (v.index: 1, p.index: 1)
act(() => {
document.querySelector<HTMLElement>('.test-provider')?.click();
});
expect(document.querySelector<HTMLElement>('.test-component')?.dataset.active).toBe('true');
expect(onUpdate).toHaveBeenCalledTimes(2);
// No match, but update because "active" changed, (v.index: 2, p.index: 1)
act(() => {
document.querySelector<HTMLElement>('.test-provider')?.click();
});
expect(document.querySelector<HTMLElement>('.test-component')?.dataset.active).toBe('false');
expect(onUpdate).toHaveBeenCalledTimes(3);
// Match previous => no update, (v.index: 3, p.index: 1)
act(() => {
document.querySelector<HTMLElement>('.test-provider')?.click();
});
expect(document.querySelector<HTMLElement>('.test-component')?.dataset.active).toBe('false');
expect(onUpdate).toHaveBeenCalledTimes(3);
});
it('updates are propogated inside React.memo()', () => {
// https://reactjs.org/docs/react-api.html#reactmemo
// Will never pass updates
const MemoComponent = React.memo(TestComponent, () => true);
const onUpdate = jest.fn();
render(
<TestProvider>
<MemoComponent index={1} onUpdate={onUpdate} />
</TestProvider>,
{ container: container as HTMLElement },
);
expect(document.querySelector<HTMLElement>('.test-component')?.dataset.active).toBe('false');
act(() => {
document.querySelector<HTMLElement>('.test-provider')?.click();
});
expect(document.querySelector<HTMLElement>('.test-component')?.dataset.active).toBe('true');
expect(onUpdate).toHaveBeenCalledTimes(2);
});
// Regression test for the eager-bailout issue with `useState().
//
// With the previous `[value, selected]` + `setState(prev => prev)` bailout, React's eager path silently stopped
// working after the first listener-driven render committed. That resulted in extra component-function calls
// on subsequent context updates, even when the selected slice didn't change.
it('memoized consumers re-render only when their selected slice changes', () => {
const MemoizedTestComponent = React.memo(TestComponent);
const onUpdates: Record<number, jest.Mock> = {
1: jest.fn(),
2: jest.fn(),
3: jest.fn(),
4: jest.fn(),
};
render(
<TestProvider>
<MemoizedTestComponent index={1} onUpdate={onUpdates[1]} />
<MemoizedTestComponent index={2} onUpdate={onUpdates[2]} />
<MemoizedTestComponent index={3} onUpdate={onUpdates[3]} />
<MemoizedTestComponent index={4} onUpdate={onUpdates[4]} />
</TestProvider>,
{ container: container as HTMLElement },
);
// Initial render. TestProvider starts at index=0, so none are active. Each memoized item has committed once — the
// `onUpdate` effect fires once per item on mount.
Object.values(onUpdates).forEach(m => expect(m).toHaveBeenCalledTimes(1));
// Click 1: 0 → 1. Only index=1 flips (active: false → true).
jest.clearAllMocks();
act(() => {
document.querySelector<HTMLElement>('.test-provider')?.click();
});
expect(onUpdates[1]).toHaveBeenCalledTimes(1); // true => false
expect(onUpdates[2]).toHaveBeenCalledTimes(0);
expect(onUpdates[3]).toHaveBeenCalledTimes(0);
expect(onUpdates[4]).toHaveBeenCalledTimes(0);
// Click 2: 1 → 2. index=1 flips (active: true → false), index=2 flips (active: false → true).
// Items 3 and 4 did not change and must not re-render.
jest.clearAllMocks();
act(() => {
document.querySelector<HTMLElement>('.test-provider')?.click();
});
expect(onUpdates[1]).toHaveBeenCalledTimes(1); // true => false
expect(onUpdates[2]).toHaveBeenCalledTimes(1); // false => true
expect(onUpdates[3]).toHaveBeenCalledTimes(0); // ← was 2 under the old eager-bailout with `useState()`
expect(onUpdates[4]).toHaveBeenCalledTimes(0);
// Click 3: 2 → 3.
jest.clearAllMocks();
act(() => {
document.querySelector<HTMLElement>('.test-provider')?.click();
});
expect(onUpdates[1]).toHaveBeenCalledTimes(0);
expect(onUpdates[2]).toHaveBeenCalledTimes(1); // true => false
expect(onUpdates[3]).toHaveBeenCalledTimes(1); // false => true
expect(onUpdates[4]).toHaveBeenCalledTimes(0);
});
it('a single consumer throw does not starve later subscribers of context updates', () => {
const ThrowingConsumer: React.FC<{ threshold: number }> = ({ threshold }) => {
useContextSelector(TestContext, v => {
if (v.index > threshold) {
throw new Error('selector cannot handle this value');
}
return v.index;
});
return null;
};
const onUpdate = jest.fn();
render(
<TestProvider>
<ThrowingConsumer threshold={0} />
<TestComponent index={1} onUpdate={onUpdate} />
</TestProvider>,
{ container: container as HTMLElement },
);
expect(onUpdate).toHaveBeenCalledTimes(1);
// Click: index goes 0 → 1. The first consumer's selector will throw
// (1 > 0). The downstream TestComponent must still receive the update
// because listener error isolation prevents `listeners.forEach` from
// short-circuiting.
act(() => {
document.querySelector<HTMLElement>('.test-provider')?.click();
});
expect(document.querySelector<HTMLElement>('.test-component')?.dataset.active).toBe('true');
expect(onUpdate).toHaveBeenCalledTimes(2);
});
});