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