main
js 444 lines 13.5 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 *
7 * @emails react-core
8 */
9
10 'use strict';
11
12 const ReactDOMServerIntegrationUtils = require('./utils/ReactDOMServerIntegrationTestUtils');
13
14 let React;
15 let ReactDOMClient;
16 let ReactDOMServer;
17
18 function initModules() {
19 // Reset warning cache.
20 jest.resetModules();
21 React = require('react');
22 ReactDOMClient = require('react-dom/client');
23 ReactDOMServer = require('react-dom/server');
24
25 // Make them available to the helpers.
26 return {
27 ReactDOMClient,
28 ReactDOMServer,
29 };
30 }
31
32 const {resetModules, itClientRenders, renderIntoDom, serverRender} =
33 ReactDOMServerIntegrationUtils(initModules);
34
35 describe('ReactDOMServerIntegrationUserInteraction', () => {
36 let ControlledInput, ControlledTextArea, ControlledCheckbox, ControlledSelect;
37
38 beforeEach(() => {
39 resetModules();
40 ControlledInput = class extends React.Component {
41 static defaultProps = {
42 type: 'text',
43 initialValue: 'Hello',
44 };
45 constructor() {
46 super(...arguments);
47 this.state = {value: this.props.initialValue};
48 }
49 handleChange(event) {
50 if (this.props.onChange) {
51 this.props.onChange(event);
52 }
53 this.setState({value: event.target.value});
54 }
55 componentDidMount() {
56 if (this.props.cascade) {
57 // Trigger a cascading render immediately upon hydration which rerenders the input.
58 this.setState({cascade: true});
59 }
60 }
61 render() {
62 return (
63 <input
64 type={this.props.type}
65 value={this.state.value}
66 onChange={this.handleChange.bind(this)}
67 />
68 );
69 }
70 };
71 ControlledTextArea = class extends React.Component {
72 constructor() {
73 super();
74 this.state = {value: 'Hello'};
75 }
76 handleChange(event) {
77 if (this.props.onChange) {
78 this.props.onChange(event);
79 }
80 this.setState({value: event.target.value});
81 }
82 componentDidMount() {
83 if (this.props.cascade) {
84 // Trigger a cascading render immediately upon hydration which rerenders the textarea.
85 this.setState({cascade: true});
86 }
87 }
88 render() {
89 return (
90 <textarea
91 value={this.state.value}
92 onChange={this.handleChange.bind(this)}
93 />
94 );
95 }
96 };
97 ControlledCheckbox = class extends React.Component {
98 constructor() {
99 super();
100 this.state = {value: true};
101 }
102 handleChange(event) {
103 if (this.props.onChange) {
104 this.props.onChange(event);
105 }
106 this.setState({value: event.target.checked});
107 }
108 componentDidMount() {
109 if (this.props.cascade) {
110 // Trigger a cascading render immediately upon hydration which rerenders the checkbox.
111 this.setState({cascade: true});
112 }
113 }
114 render() {
115 return (
116 <input
117 type="checkbox"
118 checked={this.state.value}
119 onChange={this.handleChange.bind(this)}
120 />
121 );
122 }
123 };
124 ControlledSelect = class extends React.Component {
125 constructor() {
126 super();
127 this.state = {value: 'Hello'};
128 }
129 handleChange(event) {
130 if (this.props.onChange) {
131 this.props.onChange(event);
132 }
133 this.setState({value: event.target.value});
134 }
135 componentDidMount() {
136 if (this.props.cascade) {
137 // Trigger a cascading render immediately upon hydration which rerenders the select.
138 this.setState({cascade: true});
139 }
140 }
141 render() {
142 return (
143 <select
144 value={this.state.value}
145 onChange={this.handleChange.bind(this)}>
146 <option key="1" value="Hello">
147 Hello
148 </option>
149 <option key="2" value="Goodbye">
150 Goodbye
151 </option>
152 </select>
153 );
154 }
155 };
156 });
157
158 describe('user interaction with controlled inputs', function () {
159 itClientRenders('a controlled text input', async render => {
160 const setUntrackedValue = Object.getOwnPropertyDescriptor(
161 HTMLInputElement.prototype,
162 'value',
163 ).set;
164
165 let changeCount = 0;
166 const e = await render(
167 <ControlledInput onChange={() => changeCount++} />,
168 );
169 const container = e.parentNode;
170 document.body.appendChild(container);
171
172 try {
173 expect(changeCount).toBe(0);
174 expect(e.value).toBe('Hello');
175
176 // simulate a user typing.
177 setUntrackedValue.call(e, 'Goodbye');
178 e.dispatchEvent(new Event('input', {bubbles: true, cancelable: false}));
179
180 expect(changeCount).toBe(1);
181 expect(e.value).toBe('Goodbye');
182 } finally {
183 document.body.removeChild(container);
184 }
185 });
186
187 itClientRenders('a controlled textarea', async render => {
188 const setUntrackedValue = Object.getOwnPropertyDescriptor(
189 HTMLTextAreaElement.prototype,
190 'value',
191 ).set;
192
193 let changeCount = 0;
194 const e = await render(
195 <ControlledTextArea onChange={() => changeCount++} />,
196 );
197 const container = e.parentNode;
198 document.body.appendChild(container);
199
200 try {
201 expect(changeCount).toBe(0);
202 expect(e.value).toBe('Hello');
203
204 // simulate a user typing.
205 setUntrackedValue.call(e, 'Goodbye');
206 e.dispatchEvent(new Event('input', {bubbles: true, cancelable: false}));
207
208 expect(changeCount).toBe(1);
209 expect(e.value).toBe('Goodbye');
210 } finally {
211 document.body.removeChild(container);
212 }
213 });
214
215 itClientRenders('a controlled checkbox', async render => {
216 let changeCount = 0;
217 const e = await render(
218 <ControlledCheckbox onChange={() => changeCount++} />,
219 );
220 const container = e.parentNode;
221 document.body.appendChild(container);
222
223 try {
224 expect(changeCount).toBe(0);
225 expect(e.checked).toBe(true);
226
227 // simulate a user clicking.
228 e.click();
229
230 expect(changeCount).toBe(1);
231 expect(e.checked).toBe(false);
232 } finally {
233 document.body.removeChild(container);
234 }
235 });
236
237 itClientRenders('a controlled select', async render => {
238 const setUntrackedValue = Object.getOwnPropertyDescriptor(
239 HTMLSelectElement.prototype,
240 'value',
241 ).set;
242
243 let changeCount = 0;
244 const e = await render(
245 <ControlledSelect onChange={() => changeCount++} />,
246 );
247 const container = e.parentNode;
248 document.body.appendChild(container);
249
250 try {
251 expect(changeCount).toBe(0);
252 expect(e.value).toBe('Hello');
253
254 // simulate a user typing.
255 setUntrackedValue.call(e, 'Goodbye');
256 e.dispatchEvent(
257 new Event('change', {bubbles: true, cancelable: false}),
258 );
259
260 expect(changeCount).toBe(1);
261 expect(e.value).toBe('Goodbye');
262 } finally {
263 document.body.removeChild(container);
264 }
265 });
266 });
267
268 describe('user interaction with inputs before client render', function () {
269 // renders the element and changes the value **before** the client
270 // code has a chance to render; this simulates what happens when a
271 // user starts to interact with a server-rendered form before
272 // ReactDOM.render is called. the client render should NOT blow away
273 // the changes the user has made.
274 const testUserInteractionBeforeClientRender = async (
275 element,
276 initialValue = 'Hello',
277 changedValue = 'Goodbye',
278 valueKey = 'value',
279 ) => {
280 const field = await serverRender(element);
281 expect(field[valueKey]).toBe(initialValue);
282
283 // simulate a user typing in the field **before** client-side reconnect happens.
284 field[valueKey] = changedValue;
285
286 resetModules();
287 // client render on top of the server markup.
288 const clientField = await renderIntoDom(element, field.parentNode, true);
289 // verify that the input field was not replaced.
290 // Note that we cannot use expect(clientField).toBe(field) because
291 // of jest bug #1772
292 expect(clientField === field).toBe(true);
293 // confirm that the client render has not changed what the user typed.
294 expect(clientField[valueKey]).toBe(changedValue);
295 };
296
297 it('should not blow away user-entered text on successful reconnect to an uncontrolled input', () =>
298 testUserInteractionBeforeClientRender(<input defaultValue="Hello" />));
299
300 it('should not blow away user-entered text on successful reconnect to a controlled input', async () => {
301 let changeCount = 0;
302 await testUserInteractionBeforeClientRender(
303 <ControlledInput onChange={() => changeCount++} />,
304 );
305 expect(changeCount).toBe(
306 gate(flags => flags.enableHydrationChangeEvent) ? 1 : 0,
307 );
308 });
309
310 it('should not blow away user-interaction on successful reconnect to an uncontrolled range input', () =>
311 testUserInteractionBeforeClientRender(
312 <input type="text" defaultValue="0.5" />,
313 '0.5',
314 '1',
315 ));
316
317 it('should not blow away user-interaction on successful reconnect to a controlled range input', async () => {
318 let changeCount = 0;
319 await testUserInteractionBeforeClientRender(
320 <ControlledInput
321 type="range"
322 initialValue="0.25"
323 onChange={() => changeCount++}
324 />,
325 '0.25',
326 '1',
327 );
328 expect(changeCount).toBe(
329 gate(flags => flags.enableHydrationChangeEvent) ? 1 : 0,
330 );
331 });
332
333 it('should not blow away user-entered text on successful reconnect to an uncontrolled checkbox', () =>
334 testUserInteractionBeforeClientRender(
335 <input type="checkbox" defaultChecked={true} />,
336 true,
337 false,
338 'checked',
339 ));
340
341 it('should not blow away user-entered text on successful reconnect to a controlled checkbox', async () => {
342 let changeCount = 0;
343 await testUserInteractionBeforeClientRender(
344 <ControlledCheckbox onChange={() => changeCount++} />,
345 true,
346 false,
347 'checked',
348 );
349 expect(changeCount).toBe(
350 gate(flags => flags.enableHydrationChangeEvent) ? 1 : 0,
351 );
352 });
353
354 // @gate enableHydrationChangeEvent
355 it('should not blow away user-entered text on successful reconnect to an uncontrolled textarea', () =>
356 testUserInteractionBeforeClientRender(<textarea defaultValue="Hello" />));
357
358 // @gate enableHydrationChangeEvent
359 it('should not blow away user-entered text on successful reconnect to a controlled textarea', async () => {
360 let changeCount = 0;
361 await testUserInteractionBeforeClientRender(
362 <ControlledTextArea onChange={() => changeCount++} />,
363 );
364 expect(changeCount).toBe(1);
365 });
366
367 it('should not blow away user-selected value on successful reconnect to an uncontrolled select', () =>
368 testUserInteractionBeforeClientRender(
369 <select defaultValue="Hello">
370 <option key="1" value="Hello">
371 Hello
372 </option>
373 <option key="2" value="Goodbye">
374 Goodbye
375 </option>
376 </select>,
377 ));
378
379 it('should not blow away user-selected value on successful reconnect to an controlled select', async () => {
380 let changeCount = 0;
381 await testUserInteractionBeforeClientRender(
382 <ControlledSelect onChange={() => changeCount++} />,
383 );
384 expect(changeCount).toBe(
385 gate(flags => flags.enableHydrationChangeEvent) ? 1 : 0,
386 );
387 });
388
389 // @gate enableHydrationChangeEvent
390 it('should not blow away user-entered text cascading hydration to a controlled input', async () => {
391 let changeCount = 0;
392 await testUserInteractionBeforeClientRender(
393 <ControlledInput onChange={() => changeCount++} cascade={true} />,
394 );
395 expect(changeCount).toBe(1);
396 });
397
398 // @gate enableHydrationChangeEvent
399 it('should not blow away user-interaction cascading hydration to a controlled range input', async () => {
400 let changeCount = 0;
401 await testUserInteractionBeforeClientRender(
402 <ControlledInput
403 type="range"
404 initialValue="0.25"
405 onChange={() => changeCount++}
406 cascade={true}
407 />,
408 '0.25',
409 '1',
410 );
411 expect(changeCount).toBe(1);
412 });
413
414 // @gate enableHydrationChangeEvent
415 it('should not blow away user-entered text cascading hydration to a controlled checkbox', async () => {
416 let changeCount = 0;
417 await testUserInteractionBeforeClientRender(
418 <ControlledCheckbox onChange={() => changeCount++} cascade={true} />,
419 true,
420 false,
421 'checked',
422 );
423 expect(changeCount).toBe(1);
424 });
425
426 // @gate enableHydrationChangeEvent
427 it('should not blow away user-entered text cascading hydration to a controlled textarea', async () => {
428 let changeCount = 0;
429 await testUserInteractionBeforeClientRender(
430 <ControlledTextArea onChange={() => changeCount++} cascade={true} />,
431 );
432 expect(changeCount).toBe(1);
433 });
434
435 // @gate enableHydrationChangeEvent
436 it('should not blow away user-selected value cascading hydration to an controlled select', async () => {
437 let changeCount = 0;
438 await testUserInteractionBeforeClientRender(
439 <ControlledSelect onChange={() => changeCount++} cascade={true} />,
440 );
441 expect(changeCount).toBe(1);
442 });
443 });
444 });