main
js 656 lines 19.1 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 * @flow
8 */
9
10 import type {Fiber} from './ReactInternalTypes';
11
12 import {
13 HostComponent,
14 HostHoistable,
15 HostSingleton,
16 LazyComponent,
17 ActivityComponent,
18 SuspenseComponent,
19 SuspenseListComponent,
20 FunctionComponent,
21 ForwardRef,
22 SimpleMemoComponent,
23 ClassComponent,
24 HostText,
25 } from './ReactWorkTags';
26
27 import {enableSrcObject} from 'shared/ReactFeatureFlags';
28
29 import {REACT_ELEMENT_TYPE} from 'shared/ReactSymbols';
30 import assign from 'shared/assign';
31 import getComponentNameFromType from 'shared/getComponentNameFromType';
32
33 import isArray from 'shared/isArray';
34
35 export type HydrationDiffNode = {
36 fiber: Fiber,
37 children: Array<HydrationDiffNode>,
38 serverProps: void | null | $ReadOnly<{[propName: string]: mixed}> | string, // null means no matching server node
39 serverTail: Array<
40 | $ReadOnly<{type: string, props: $ReadOnly<{[propName: string]: mixed}>}>
41 | string,
42 >,
43 distanceFromLeaf: number,
44 };
45
46 const maxRowLength = 120;
47 const idealDepth = 15;
48
49 function findNotableNode(
50 node: HydrationDiffNode,
51 indent: number,
52 ): HydrationDiffNode {
53 if (
54 node.serverProps === undefined &&
55 node.serverTail.length === 0 &&
56 node.children.length === 1 &&
57 node.distanceFromLeaf > 3 &&
58 node.distanceFromLeaf > idealDepth - indent
59 ) {
60 // This is not an interesting node for contextual purposes so we can skip it.
61 const child = node.children[0];
62 return findNotableNode(child, indent);
63 }
64 return node;
65 }
66
67 function indentation(indent: number): string {
68 return ' ' + ' '.repeat(indent);
69 }
70
71 function added(indent: number): string {
72 return '+ ' + ' '.repeat(indent);
73 }
74
75 function removed(indent: number): string {
76 return '- ' + ' '.repeat(indent);
77 }
78
79 function describeFiberType(fiber: Fiber): null | string {
80 switch (fiber.tag) {
81 case HostHoistable:
82 case HostSingleton:
83 case HostComponent:
84 return fiber.type;
85 case LazyComponent:
86 return 'Lazy';
87 case ActivityComponent:
88 return 'Activity';
89 case SuspenseComponent:
90 return 'Suspense';
91 case SuspenseListComponent:
92 return 'SuspenseList';
93 case FunctionComponent:
94 case SimpleMemoComponent:
95 const fn = fiber.type;
96 return fn.displayName || fn.name || null;
97 case ForwardRef:
98 const render = fiber.type.render;
99 return render.displayName || render.name || null;
100 case ClassComponent:
101 const ctr = fiber.type;
102 return ctr.displayName || ctr.name || null;
103 default:
104 // Skip
105 return null;
106 }
107 }
108
109 const needsEscaping = /["'&<>\n\t]|^\s|\s$/;
110
111 function describeTextNode(content: string, maxLength: number): string {
112 if (needsEscaping.test(content)) {
113 const encoded = JSON.stringify(content);
114 if (encoded.length > maxLength - 2) {
115 if (maxLength < 8) {
116 return '{"..."}';
117 }
118 return '{' + encoded.slice(0, maxLength - 7) + '..."}';
119 }
120 return '{' + encoded + '}';
121 } else {
122 if (content.length > maxLength) {
123 if (maxLength < 5) {
124 return '{"..."}';
125 }
126 return content.slice(0, maxLength - 3) + '...';
127 }
128 return content;
129 }
130 }
131
132 function describeTextDiff(
133 clientText: string,
134 serverProps: mixed,
135 indent: number,
136 ): string {
137 const maxLength = maxRowLength - indent * 2;
138 if (serverProps === null) {
139 return added(indent) + describeTextNode(clientText, maxLength) + '\n';
140 } else if (typeof serverProps === 'string') {
141 let serverText: string = serverProps;
142 let firstDiff = 0;
143 for (
144 ;
145 firstDiff < serverText.length && firstDiff < clientText.length;
146 firstDiff++
147 ) {
148 if (
149 serverText.charCodeAt(firstDiff) !== clientText.charCodeAt(firstDiff)
150 ) {
151 break;
152 }
153 }
154 if (firstDiff > maxLength - 8 && firstDiff > 10) {
155 // The first difference between the two strings would be cut off, so cut off in
156 // the beginning instead.
157 clientText = '...' + clientText.slice(firstDiff - 8);
158 serverText = '...' + serverText.slice(firstDiff - 8);
159 }
160 return (
161 added(indent) +
162 describeTextNode(clientText, maxLength) +
163 '\n' +
164 removed(indent) +
165 describeTextNode(serverText, maxLength) +
166 '\n'
167 );
168 } else {
169 return indentation(indent) + describeTextNode(clientText, maxLength) + '\n';
170 }
171 }
172
173 function objectName(object: mixed): string {
174 // $FlowFixMe[method-unbinding]
175 const name = Object.prototype.toString.call(object);
176 return name.replace(/^\[object (.*)\]$/, function (m, p0) {
177 return p0;
178 });
179 }
180
181 function describeValue(value: mixed, maxLength: number): string {
182 switch (typeof value) {
183 case 'string': {
184 const encoded = JSON.stringify(value);
185 if (encoded.length > maxLength) {
186 if (maxLength < 5) {
187 return '"..."';
188 }
189 return encoded.slice(0, maxLength - 4) + '..."';
190 }
191 return encoded;
192 }
193 case 'object': {
194 if (value === null) {
195 return 'null';
196 }
197 if (isArray(value)) {
198 return '[...]';
199 }
200 if ((value as any).$$typeof === REACT_ELEMENT_TYPE) {
201 const type = getComponentNameFromType((value as any).type);
202 return type ? '<' + type + '>' : '<...>';
203 }
204 const name = objectName(value);
205 if (name === 'Object') {
206 let properties = '';
207 maxLength -= 2;
208 for (let propName in value) {
209 if (!value.hasOwnProperty(propName)) {
210 continue;
211 }
212 const jsonPropName = JSON.stringify(propName);
213 if (jsonPropName !== '"' + propName + '"') {
214 propName = jsonPropName;
215 }
216 maxLength -= propName.length - 2;
217 const propValue = describeValue(
218 value[propName],
219 maxLength < 15 ? maxLength : 15,
220 );
221 maxLength -= propValue.length;
222 if (maxLength < 0) {
223 properties += properties === '' ? '...' : ', ...';
224 break;
225 }
226 properties +=
227 (properties === '' ? '' : ',') + propName + ':' + propValue;
228 }
229 return '{' + properties + '}';
230 } else if (enableSrcObject && (name === 'Blob' || name === 'File')) {
231 return name + ':' + (value as any).type;
232 }
233 return name;
234 }
235 case 'function': {
236 const name = (value as any).displayName || value.name;
237 return name ? 'function ' + name : 'function';
238 }
239 default:
240 // eslint-disable-next-line react-internal/safe-string-coercion
241 return String(value);
242 }
243 }
244
245 function describePropValue(value: mixed, maxLength: number): string {
246 if (typeof value === 'string' && !needsEscaping.test(value)) {
247 if (value.length > maxLength - 2) {
248 if (maxLength < 5) {
249 return '"..."';
250 }
251 return '"' + value.slice(0, maxLength - 5) + '..."';
252 }
253 return '"' + value + '"';
254 }
255 return '{' + describeValue(value, maxLength - 2) + '}';
256 }
257
258 function describeCollapsedElement(
259 type: string,
260 props: {[propName: string]: mixed},
261 indent: number,
262 ): string {
263 // This function tries to fit the props into a single line for non-essential elements.
264 // We also ignore children because we're not going deeper.
265
266 let maxLength = maxRowLength - indent * 2 - type.length - 2;
267
268 let content = '';
269
270 for (const propName in props) {
271 if (!props.hasOwnProperty(propName)) {
272 continue;
273 }
274 if (propName === 'children') {
275 // Ignored.
276 continue;
277 }
278 const propValue = describePropValue(props[propName], 15);
279 maxLength -= propName.length + propValue.length + 2;
280 if (maxLength < 0) {
281 content += ' ...';
282 break;
283 }
284 content += ' ' + propName + '=' + propValue;
285 }
286
287 return indentation(indent) + '<' + type + content + '>\n';
288 }
289
290 function describeExpandedElement(
291 type: string,
292 props: {+[propName: string]: mixed},
293 rowPrefix: string,
294 ): string {
295 // This function tries to fit the props into a single line for non-essential elements.
296 // We also ignore children because we're not going deeper.
297
298 let remainingRowLength = maxRowLength - rowPrefix.length - type.length;
299
300 // We add the properties to a set so we can choose later whether we'll put it on one
301 // line or multiple lines.
302
303 const properties = [];
304
305 for (const propName in props) {
306 if (!props.hasOwnProperty(propName)) {
307 continue;
308 }
309 if (propName === 'children') {
310 // Ignored.
311 continue;
312 }
313 const maxLength = maxRowLength - rowPrefix.length - propName.length - 1;
314 const propValue = describePropValue(props[propName], maxLength);
315 remainingRowLength -= propName.length + propValue.length + 2;
316 properties.push(propName + '=' + propValue);
317 }
318
319 if (properties.length === 0) {
320 return rowPrefix + '<' + type + '>\n';
321 } else if (remainingRowLength > 0) {
322 // We can fit all on one row.
323 return rowPrefix + '<' + type + ' ' + properties.join(' ') + '>\n';
324 } else {
325 // Split into one row per property:
326 return (
327 rowPrefix +
328 '<' +
329 type +
330 '\n' +
331 rowPrefix +
332 ' ' +
333 properties.join('\n' + rowPrefix + ' ') +
334 '\n' +
335 rowPrefix +
336 '>\n'
337 );
338 }
339 }
340
341 function describePropertiesDiff(
342 clientObject: {+[propName: string]: mixed},
343 serverObject: {+[propName: string]: mixed},
344 indent: number,
345 ): string {
346 let properties = '';
347 const remainingServerProperties = assign({}, serverObject);
348 for (const propName in clientObject) {
349 if (!clientObject.hasOwnProperty(propName)) {
350 continue;
351 }
352 delete remainingServerProperties[propName];
353 const maxLength = maxRowLength - indent * 2 - propName.length - 2;
354 const clientValue = clientObject[propName];
355 const clientPropValue = describeValue(clientValue, maxLength);
356 if (serverObject.hasOwnProperty(propName)) {
357 const serverValue = serverObject[propName];
358 const serverPropValue = describeValue(serverValue, maxLength);
359 properties += added(indent) + propName + ': ' + clientPropValue + '\n';
360 properties += removed(indent) + propName + ': ' + serverPropValue + '\n';
361 } else {
362 properties += added(indent) + propName + ': ' + clientPropValue + '\n';
363 }
364 }
365 for (const propName in remainingServerProperties) {
366 if (!remainingServerProperties.hasOwnProperty(propName)) {
367 continue;
368 }
369 const maxLength = maxRowLength - indent * 2 - propName.length - 2;
370 const serverValue = remainingServerProperties[propName];
371 const serverPropValue = describeValue(serverValue, maxLength);
372 properties += removed(indent) + propName + ': ' + serverPropValue + '\n';
373 }
374 return properties;
375 }
376
377 function describeElementDiff(
378 type: string,
379 clientProps: {+[propName: string]: mixed},
380 serverProps: {+[propName: string]: mixed},
381 indent: number,
382 ): string {
383 let content = '';
384
385 // Maps any previously unmatched lower case server prop name to its full prop name
386 const serverPropNames: Map<string, string> = new Map();
387
388 for (const propName in serverProps) {
389 if (!serverProps.hasOwnProperty(propName)) {
390 continue;
391 }
392 serverPropNames.set(propName.toLowerCase(), propName);
393 }
394
395 if (serverPropNames.size === 1 && serverPropNames.has('children')) {
396 content += describeExpandedElement(type, clientProps, indentation(indent));
397 } else {
398 for (const propName in clientProps) {
399 if (!clientProps.hasOwnProperty(propName)) {
400 continue;
401 }
402 if (propName === 'children') {
403 // Handled below.
404 continue;
405 }
406 const maxLength = maxRowLength - (indent + 1) * 2 - propName.length - 1;
407 const serverPropName = serverPropNames.get(propName.toLowerCase());
408 if (serverPropName !== undefined) {
409 serverPropNames.delete(propName.toLowerCase());
410 // There's a diff here.
411 const clientValue = clientProps[propName];
412 const serverValue = serverProps[serverPropName];
413 const clientPropValue = describePropValue(clientValue, maxLength);
414 const serverPropValue = describePropValue(serverValue, maxLength);
415 if (
416 typeof clientValue === 'object' &&
417 clientValue !== null &&
418 typeof serverValue === 'object' &&
419 serverValue !== null &&
420 objectName(clientValue) === 'Object' &&
421 objectName(serverValue) === 'Object' &&
422 // Only do the diff if the object has a lot of keys or was shortened.
423 (Object.keys(clientValue).length > 2 ||
424 Object.keys(serverValue).length > 2 ||
425 clientPropValue.indexOf('...') > -1 ||
426 serverPropValue.indexOf('...') > -1)
427 ) {
428 // We're comparing two plain objects. We can diff the nested objects instead.
429 content +=
430 indentation(indent + 1) +
431 propName +
432 '={{\n' +
433 describePropertiesDiff(clientValue, serverValue, indent + 2) +
434 indentation(indent + 1) +
435 '}}\n';
436 } else {
437 content +=
438 added(indent + 1) + propName + '=' + clientPropValue + '\n';
439 content +=
440 removed(indent + 1) + propName + '=' + serverPropValue + '\n';
441 }
442 } else {
443 // Considered equal.
444 content +=
445 indentation(indent + 1) +
446 propName +
447 '=' +
448 describePropValue(clientProps[propName], maxLength) +
449 '\n';
450 }
451 }
452
453 serverPropNames.forEach(propName => {
454 if (propName === 'children') {
455 // Handled below.
456 return;
457 }
458 const maxLength = maxRowLength - (indent + 1) * 2 - propName.length - 1;
459 content +=
460 removed(indent + 1) +
461 propName +
462 '=' +
463 describePropValue(serverProps[propName], maxLength) +
464 '\n';
465 });
466
467 if (content === '') {
468 // No properties
469 content = indentation(indent) + '<' + type + '>\n';
470 } else {
471 // Had properties
472 content =
473 indentation(indent) +
474 '<' +
475 type +
476 '\n' +
477 content +
478 indentation(indent) +
479 '>\n';
480 }
481 }
482
483 const serverChildren = serverProps.children;
484 const clientChildren = clientProps.children;
485 if (
486 typeof serverChildren === 'string' ||
487 typeof serverChildren === 'number' ||
488 typeof serverChildren === 'bigint'
489 ) {
490 // There's a diff of the children.
491 // $FlowFixMe[unsafe-addition]
492 const serverText = '' + serverChildren;
493 let clientText = '';
494 if (
495 typeof clientChildren === 'string' ||
496 typeof clientChildren === 'number' ||
497 typeof clientChildren === 'bigint'
498 ) {
499 // $FlowFixMe[unsafe-addition]
500 clientText = '' + clientChildren;
501 }
502 content += describeTextDiff(clientText, serverText, indent + 1);
503 } else if (
504 typeof clientChildren === 'string' ||
505 typeof clientChildren === 'number' ||
506 typeof clientChildren === 'bigint'
507 ) {
508 if (serverChildren == null) {
509 // This is a new string child.
510 // $FlowFixMe[unsafe-addition]
511 content += describeTextDiff('' + clientChildren, null, indent + 1);
512 } else {
513 // The client has children but it's not considered a difference from the server.
514 // $FlowFixMe[unsafe-addition]
515 content += describeTextDiff('' + clientChildren, undefined, indent + 1);
516 }
517 }
518 return content;
519 }
520
521 function describeSiblingFiber(fiber: Fiber, indent: number): string {
522 const type = describeFiberType(fiber);
523 if (type === null) {
524 // Skip this type of fiber. We currently treat this as a fragment
525 // so it's just part of the parent's children.
526 let flatContent = '';
527 let childFiber = fiber.child;
528 while (childFiber) {
529 flatContent += describeSiblingFiber(childFiber, indent);
530 childFiber = childFiber.sibling;
531 }
532 return flatContent;
533 }
534 return indentation(indent) + '<' + type + '>' + '\n';
535 }
536
537 function describeNode(node: HydrationDiffNode, indent: number): string {
538 const skipToNode = findNotableNode(node, indent);
539 if (
540 skipToNode !== node &&
541 (node.children.length !== 1 || node.children[0] !== skipToNode)
542 ) {
543 return indentation(indent) + '...\n' + describeNode(skipToNode, indent + 1);
544 }
545
546 // Prefix with any server components for context
547 let parentContent = '';
548 const debugInfo = node.fiber._debugInfo;
549 if (debugInfo) {
550 for (let i = 0; i < debugInfo.length; i++) {
551 const serverComponentName = debugInfo[i].name;
552 if (typeof serverComponentName === 'string') {
553 parentContent +=
554 indentation(indent) + '<' + serverComponentName + '>' + '\n';
555 indent++;
556 }
557 }
558 }
559
560 // Self
561 let selfContent = '';
562
563 // We use the pending props since we might be generating a diff before the complete phase
564 // when something throws.
565 const clientProps = node.fiber.pendingProps;
566
567 if (node.fiber.tag === HostText) {
568 // Text Node
569 selfContent = describeTextDiff(clientProps, node.serverProps, indent);
570 indent++;
571 } else {
572 const type = describeFiberType(node.fiber);
573 if (type !== null) {
574 // Element Node
575 if (node.serverProps === undefined) {
576 // Just a reference node for context.
577 selfContent = describeCollapsedElement(type, clientProps, indent);
578 indent++;
579 } else if (node.serverProps === null) {
580 selfContent = describeExpandedElement(type, clientProps, added(indent));
581 indent++;
582 } else if (typeof node.serverProps === 'string') {
583 if (__DEV__) {
584 console.error(
585 'Should not have matched a non HostText fiber to a Text node. This is a bug in React.',
586 );
587 }
588 } else {
589 selfContent = describeElementDiff(
590 type,
591 clientProps,
592 node.serverProps,
593 indent,
594 );
595 indent++;
596 }
597 }
598 }
599
600 // Compute children
601 let childContent = '';
602 let childFiber = node.fiber.child;
603 let diffIdx = 0;
604 while (childFiber && diffIdx < node.children.length) {
605 const childNode = node.children[diffIdx];
606 if (childNode.fiber === childFiber) {
607 // This was a match in the diff.
608 childContent += describeNode(childNode, indent);
609 diffIdx++;
610 } else {
611 // This is an unrelated previous sibling.
612 childContent += describeSiblingFiber(childFiber, indent);
613 }
614 childFiber = childFiber.sibling;
615 }
616
617 if (childFiber && node.children.length > 0) {
618 // If we had any further siblings after the last mismatch, we can't be sure if it's
619 // actually a valid match since it might not have found a match. So we exclude next
620 // siblings to avoid confusion.
621 childContent += indentation(indent) + '...' + '\n';
622 }
623
624 // Deleted tail nodes
625 const serverTail = node.serverTail;
626 if (node.serverProps === null) {
627 indent--;
628 }
629 for (let i = 0; i < serverTail.length; i++) {
630 const tailNode = serverTail[i];
631 if (typeof tailNode === 'string') {
632 // Removed text node
633 childContent +=
634 removed(indent) +
635 describeTextNode(tailNode, maxRowLength - indent * 2) +
636 '\n';
637 } else {
638 // Removed element
639 childContent += describeExpandedElement(
640 tailNode.type,
641 tailNode.props,
642 removed(indent),
643 );
644 }
645 }
646
647 return parentContent + selfContent + childContent;
648 }
649
650 export function describeDiff(rootNode: HydrationDiffNode): string {
651 try {
652 return '\n\n' + describeNode(rootNode, 0);
653 } catch (x) {
654 return '';
655 }
656 }