main
js 345 lines 10.4 KB
Raw
1 let React;
2 let ReactDOM;
3 let ReactDOMClient;
4 let Scheduler;
5 let act;
6 let useState;
7 let useEffect;
8 let startTransition;
9 let assertLog;
10 let assertConsoleErrorDev;
11 let waitForPaint;
12
13 // TODO: Migrate tests to React DOM instead of React Noop
14
15 describe('ReactFlushSync', () => {
16 beforeEach(() => {
17 jest.resetModules();
18
19 React = require('react');
20 ReactDOM = require('react-dom');
21 ReactDOMClient = require('react-dom/client');
22 Scheduler = require('scheduler');
23 act = require('internal-test-utils').act;
24 useState = React.useState;
25 useEffect = React.useEffect;
26 startTransition = React.startTransition;
27
28 const InternalTestUtils = require('internal-test-utils');
29 assertLog = InternalTestUtils.assertLog;
30 assertConsoleErrorDev = InternalTestUtils.assertConsoleErrorDev;
31 waitForPaint = InternalTestUtils.waitForPaint;
32 });
33
34 function Text({text}) {
35 Scheduler.log(text);
36 return text;
37 }
38
39 function getVisibleChildren(element: Element): React$Node {
40 const children = [];
41 let node: any = element.firstChild;
42 while (node) {
43 if (node.nodeType === 1) {
44 if (
45 ((node.tagName !== 'SCRIPT' && node.tagName !== 'script') ||
46 node.hasAttribute('data-meaningful')) &&
47 node.tagName !== 'TEMPLATE' &&
48 node.tagName !== 'template' &&
49 !node.hasAttribute('hidden') &&
50 !node.hasAttribute('aria-hidden')
51 ) {
52 const props: any = {};
53 const attributes = node.attributes;
54 for (let i = 0; i < attributes.length; i++) {
55 if (
56 attributes[i].name === 'id' &&
57 attributes[i].value.includes(':')
58 ) {
59 // We assume this is a React added ID that's a non-visual implementation detail.
60 continue;
61 }
62 props[attributes[i].name] = attributes[i].value;
63 }
64 props.children = getVisibleChildren(node);
65 children.push(
66 require('react').createElement(node.tagName.toLowerCase(), props),
67 );
68 }
69 } else if (node.nodeType === 3) {
70 children.push(node.data);
71 }
72 node = node.nextSibling;
73 }
74 return children.length === 0
75 ? undefined
76 : children.length === 1
77 ? children[0]
78 : children;
79 }
80
81 it('changes priority of updates in useEffect', async () => {
82 function App() {
83 const [syncState, setSyncState] = useState(0);
84 const [state, setState] = useState(0);
85 useEffect(() => {
86 if (syncState !== 1) {
87 setState(1);
88 ReactDOM.flushSync(() => setSyncState(1));
89 }
90 }, [syncState, state]);
91 return <Text text={`${syncState}, ${state}`} />;
92 }
93
94 const container = document.createElement('div');
95 const root = ReactDOMClient.createRoot(container);
96 await act(async () => {
97 React.startTransition(() => {
98 root.render(<App />);
99 });
100 // This will yield right before the passive effect fires
101 await waitForPaint(['0, 0']);
102
103 // The passive effect will schedule a sync update and a normal update.
104 // They should commit in two separate batches. First the sync one.
105 await waitForPaint(['1, 1']);
106
107 // The remaining update is not sync
108 ReactDOM.flushSync();
109 assertLog([]);
110 assertConsoleErrorDev([
111 'flushSync was called from inside a lifecycle method. React ' +
112 'cannot flush when React is already rendering. Consider moving this ' +
113 'call to a scheduler task or micro task.' +
114 '\n in App',
115 ]);
116
117 await waitForPaint([]);
118 });
119 expect(getVisibleChildren(container)).toEqual('1, 1');
120 });
121
122 it('supports nested flushSync with startTransition', async () => {
123 let setSyncState;
124 let setState;
125 function App() {
126 const [syncState, _setSyncState] = useState(0);
127 const [state, _setState] = useState(0);
128 setSyncState = _setSyncState;
129 setState = _setState;
130 return <Text text={`${syncState}, ${state}`} />;
131 }
132
133 const container = document.createElement('div');
134 const root = ReactDOMClient.createRoot(container);
135 await act(() => {
136 root.render(<App />);
137 });
138 assertLog(['0, 0']);
139 expect(getVisibleChildren(container)).toEqual('0, 0');
140
141 await act(() => {
142 ReactDOM.flushSync(() => {
143 startTransition(() => {
144 // This should be async even though flushSync is on the stack, because
145 // startTransition is closer.
146 setState(1);
147 ReactDOM.flushSync(() => {
148 // This should be async even though startTransition is on the stack,
149 // because flushSync is closer.
150 setSyncState(1);
151 });
152 });
153 });
154 // Only the sync update should have flushed
155 assertLog(['1, 0']);
156 expect(getVisibleChildren(container)).toEqual('1, 0');
157 });
158 // Now the async update has flushed, too.
159 assertLog(['1, 1']);
160 expect(getVisibleChildren(container)).toEqual('1, 1');
161 });
162
163 it('flushes passive effects synchronously when they are the result of a sync render', async () => {
164 function App() {
165 useEffect(() => {
166 Scheduler.log('Effect');
167 }, []);
168 return <Text text="Child" />;
169 }
170
171 const container = document.createElement('div');
172 const root = ReactDOMClient.createRoot(container);
173 await act(() => {
174 ReactDOM.flushSync(() => {
175 root.render(<App />);
176 });
177 assertLog([
178 'Child',
179 // Because the pending effect was the result of a sync update, calling
180 // flushSync should flush it.
181 'Effect',
182 ]);
183 expect(getVisibleChildren(container)).toEqual('Child');
184 });
185 });
186
187 // @gate !disableLegacyMode
188 it('does not flush passive effects synchronously after render in legacy mode', async () => {
189 function App() {
190 useEffect(() => {
191 Scheduler.log('Effect');
192 }, []);
193 return <Text text="Child" />;
194 }
195
196 const container = document.createElement('div');
197 await act(() => {
198 ReactDOM.flushSync(() => {
199 ReactDOM.render(<App />, container);
200 });
201 assertLog([
202 'Child',
203 // Because we're in legacy mode, we shouldn't have flushed the passive
204 // effects yet.
205 ]);
206 expect(getVisibleChildren(container)).toEqual('Child');
207 });
208 // Effect flushes after paint.
209 assertLog(['Effect']);
210 });
211
212 // @gate !disableLegacyMode
213 it('flushes pending passive effects before scope is called in legacy mode', async () => {
214 let currentStep = 0;
215
216 function App({step}) {
217 useEffect(() => {
218 currentStep = step;
219 Scheduler.log('Effect: ' + step);
220 }, [step]);
221 return <Text text={step} />;
222 }
223
224 const container = document.createElement('div');
225 await act(() => {
226 ReactDOM.flushSync(() => {
227 ReactDOM.render(<App step={1} />, container);
228 });
229 assertLog([
230 1,
231 // Because we're in legacy mode, we shouldn't have flushed the passive
232 // effects yet.
233 ]);
234 expect(getVisibleChildren(container)).toEqual('1');
235
236 ReactDOM.flushSync(() => {
237 // This should render step 2 because the passive effect has already
238 // fired, before the scope function is called.
239 ReactDOM.render(<App step={currentStep + 1} />, container);
240 });
241 assertLog(['Effect: 1', 2]);
242 expect(getVisibleChildren(container)).toEqual('2');
243 });
244 assertLog(['Effect: 2']);
245 });
246
247 it("does not flush passive effects synchronously when they aren't the result of a sync render", async () => {
248 function App() {
249 useEffect(() => {
250 Scheduler.log('Effect');
251 }, []);
252 return <Text text="Child" />;
253 }
254
255 const container = document.createElement('div');
256 const root = ReactDOMClient.createRoot(container);
257 await act(async () => {
258 root.render(<App />);
259 await waitForPaint([
260 'Child',
261 // Because the passive effect was not the result of a sync update, it
262 // should not flush before paint.
263 ]);
264 expect(getVisibleChildren(container)).toEqual('Child');
265 });
266 // Effect flushes after paint.
267 assertLog(['Effect']);
268 });
269
270 it('does not flush pending passive effects', async () => {
271 function App() {
272 useEffect(() => {
273 Scheduler.log('Effect');
274 }, []);
275 return <Text text="Child" />;
276 }
277
278 const container = document.createElement('div');
279 const root = ReactDOMClient.createRoot(container);
280 await act(async () => {
281 root.render(<App />);
282 await waitForPaint(['Child']);
283 expect(getVisibleChildren(container)).toEqual('Child');
284
285 // Passive effects are pending. Calling flushSync should not affect them.
286 ReactDOM.flushSync();
287 // Effects still haven't fired.
288 assertLog([]);
289 });
290 // Now the effects have fired.
291 assertLog(['Effect']);
292 });
293
294 it('completely exhausts synchronous work queue even if something throws', async () => {
295 function Throws({error}) {
296 throw error;
297 }
298
299 const container1 = document.createElement('div');
300 const root1 = ReactDOMClient.createRoot(container1);
301
302 const container2 = document.createElement('div');
303 const root2 = ReactDOMClient.createRoot(container2);
304
305 const container3 = document.createElement('div');
306 const root3 = ReactDOMClient.createRoot(container3);
307
308 await act(async () => {
309 root1.render(<Text text="Hi" />);
310 root2.render(<Text text="Andrew" />);
311 root3.render(<Text text="!" />);
312 });
313 assertLog(['Hi', 'Andrew', '!']);
314
315 const aahh = new Error('AAHH!');
316 const nooo = new Error('Noooooooooo!');
317
318 let error;
319 try {
320 await act(() => {
321 ReactDOM.flushSync(() => {
322 root1.render(<Throws error={aahh} />);
323 root2.render(<Throws error={nooo} />);
324 root3.render(<Text text="aww" />);
325 });
326 });
327 } catch (e) {
328 error = e;
329 }
330
331 // The update to root 3 should have finished synchronously, even though the
332 // earlier updates errored.
333 assertLog(['aww']);
334 // Roots 1 and 2 were unmounted.
335 expect(getVisibleChildren(container1)).toEqual(undefined);
336 expect(getVisibleChildren(container2)).toEqual(undefined);
337 expect(getVisibleChildren(container3)).toEqual('aww');
338
339 // Because there were multiple errors, React threw an AggregateError.
340 expect(error).toBeInstanceOf(AggregateError);
341 expect(error.errors.length).toBe(2);
342 expect(error.errors[0]).toBe(aahh);
343 expect(error.errors[1]).toBe(nooo);
344 });
345 });