/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * * @flow */ import {OMITTED_PROP_ERROR} from 'shared/ReactFlightPropertyAccess'; import hasOwnProperty from 'shared/hasOwnProperty'; import isArray from 'shared/isArray'; import {REACT_ELEMENT_TYPE} from './ReactSymbols'; import getComponentNameFromType from './getComponentNameFromType'; const EMPTY_ARRAY = 0; const COMPLEX_ARRAY = 1; const PRIMITIVE_ARRAY = 2; // Primitive values only const ENTRIES_ARRAY = 3; // Tuple arrays of string and value (like Headers, Map, etc) function getArrayKind(array: Object): 0 | 1 | 2 | 3 { let kind: 0 | 1 | 2 | 3 = EMPTY_ARRAY; for (let i = 0; i < array.length; i++) { const value = array[i]; if (typeof value === 'object' && value !== null) { if ( isArray(value) && value.length === 2 && typeof value[0] === 'string' ) { // Key value tuple if (kind !== EMPTY_ARRAY && kind !== ENTRIES_ARRAY) { return COMPLEX_ARRAY; } kind = ENTRIES_ARRAY; } else { return COMPLEX_ARRAY; } } else if (typeof value === 'function') { return COMPLEX_ARRAY; } else if (typeof value === 'string' && value.length > 50) { return COMPLEX_ARRAY; } else if (kind !== EMPTY_ARRAY && kind !== PRIMITIVE_ARRAY) { return COMPLEX_ARRAY; } else { kind = PRIMITIVE_ARRAY; } } return kind; } export function addObjectToProperties( object: Object, properties: Array<[string, string]>, indent: number, prefix: string, ): void { for (const key in object) { if (hasOwnProperty.call(object, key) && key[0] !== '_') { const value = object[key]; addValueToProperties(key, value, properties, indent, prefix); } } } export function addValueToProperties( propertyName: string, value: mixed, properties: Array<[string, string]>, indent: number, prefix: string, ): void { let desc; switch (typeof value) { case 'object': if (value === null) { desc = 'null'; break; } else { if (value.$$typeof === REACT_ELEMENT_TYPE) { // JSX const typeName = getComponentNameFromType(value.type) || '\u2026'; const key = value.key; const props: any = value.props; const propsKeys = Object.keys(props); const propsLength = propsKeys.length; if (key == null && propsLength === 0) { desc = '<' + typeName + ' />'; break; } if ( indent < 3 || (propsLength === 1 && propsKeys[0] === 'children' && key == null) ) { desc = '<' + typeName + ' \u2026 />'; break; } properties.push([ prefix + '\xa0\xa0'.repeat(indent) + propertyName, '<' + typeName, ]); if (key !== null) { addValueToProperties('key', key, properties, indent + 1, prefix); } let hasChildren = false; for (const propKey in props) { if (propKey === 'children') { if ( props.children != null && (!isArray(props.children) || props.children.length > 0) ) { hasChildren = true; } } else if ( hasOwnProperty.call(props, propKey) && propKey[0] !== '_' ) { addValueToProperties( propKey, props[propKey], properties, indent + 1, prefix, ); } } properties.push([ '', hasChildren ? '>\u2026' : '/>', ]); return; } // $FlowFixMe[method-unbinding] const objectToString = Object.prototype.toString.call(value); let objectName = objectToString.slice(8, objectToString.length - 1); if (objectName === 'Array') { const array: Array = (value: any); const kind = getArrayKind(array); if (kind === PRIMITIVE_ARRAY || kind === EMPTY_ARRAY) { desc = JSON.stringify(array); break; } else if (kind === ENTRIES_ARRAY) { properties.push([ prefix + '\xa0\xa0'.repeat(indent) + propertyName, '', ]); for (let i = 0; i < array.length; i++) { const entry = array[i]; addValueToProperties( entry[0], entry[1], properties, indent + 1, prefix, ); } return; } } if (objectName === 'Promise') { if (value.status === 'fulfilled') { // Print the inner value const idx = properties.length; addValueToProperties( propertyName, value.value, properties, indent, prefix, ); if (properties.length > idx) { // Wrap the value or type in Promise descriptor. const insertedEntry = properties[idx]; insertedEntry[1] = 'Promise<' + (insertedEntry[1] || 'Object') + '>'; return; } } else if (value.status === 'rejected') { // Print the inner error const idx = properties.length; addValueToProperties( propertyName, value.reason, properties, indent, prefix, ); if (properties.length > idx) { // Wrap the value or type in Promise descriptor. const insertedEntry = properties[idx]; insertedEntry[1] = 'Rejected Promise<' + insertedEntry[1] + '>'; return; } } properties.push([ '\xa0\xa0'.repeat(indent) + propertyName, 'Promise', ]); return; } if (objectName === 'Object') { const proto: any = Object.getPrototypeOf(value); if (proto && typeof proto.constructor === 'function') { objectName = proto.constructor.name; } } properties.push([ prefix + '\xa0\xa0'.repeat(indent) + propertyName, objectName === 'Object' ? (indent < 3 ? '' : '\u2026') : objectName, ]); if (indent < 3) { addObjectToProperties(value, properties, indent + 1, prefix); } return; } case 'function': if (value.name === '') { desc = '() => {}'; } else { desc = value.name + '() {}'; } break; case 'string': if (value === OMITTED_PROP_ERROR) { desc = '\u2026'; // ellipsis } else { desc = JSON.stringify(value); } break; case 'undefined': desc = 'undefined'; break; case 'boolean': desc = value ? 'true' : 'false'; break; default: // eslint-disable-next-line react-internal/safe-string-coercion desc = String(value); } properties.push([prefix + '\xa0\xa0'.repeat(indent) + propertyName, desc]); } const REMOVED = '\u2013\xa0'; const ADDED = '+\xa0'; const UNCHANGED = '\u2007\xa0'; export function addObjectDiffToProperties( prev: Object, next: Object, properties: Array<[string, string]>, indent: number, ): boolean { // Note: We diff even non-owned properties here but things that are shared end up just the same. // If a property is added or removed, we just emit the property name and omit the value it had. // Mainly for performance. We need to minimize to only relevant information. let isDeeplyEqual = true; for (const key in prev) { if (!(key in next)) { properties.push([REMOVED + '\xa0\xa0'.repeat(indent) + key, '\u2026']); isDeeplyEqual = false; } } for (const key in next) { if (key in prev) { const prevValue = prev[key]; const nextValue = next[key]; if (prevValue !== nextValue) { if (indent === 0 && key === 'children') { // Omit any change inside the top level children prop since it's expected to change // with any change to children of the component and their props will be logged // elsewhere but still mark it as a cause of render. const line = '\xa0\xa0'.repeat(indent) + key; properties.push([REMOVED + line, '\u2026'], [ADDED + line, '\u2026']); isDeeplyEqual = false; continue; } if (indent >= 3) { // Just fallthrough to print the two values if we're deep. // This will skip nested properties of the objects. } else if ( typeof prevValue === 'object' && typeof nextValue === 'object' && prevValue !== null && nextValue !== null && prevValue.$$typeof === nextValue.$$typeof ) { if (nextValue.$$typeof === REACT_ELEMENT_TYPE) { if ( prevValue.type === nextValue.type && prevValue.key === nextValue.key ) { // If the only thing that has changed is the props of a nested element, then // we omit the props because it is likely to be represented as a diff elsewhere. const typeName = getComponentNameFromType(nextValue.type) || '\u2026'; const line = '\xa0\xa0'.repeat(indent) + key; const desc = '<' + typeName + ' \u2026 />'; properties.push([REMOVED + line, desc], [ADDED + line, desc]); isDeeplyEqual = false; continue; } } else { // $FlowFixMe[method-unbinding] const prevKind = Object.prototype.toString.call(prevValue); // $FlowFixMe[method-unbinding] const nextKind = Object.prototype.toString.call(nextValue); if ( prevKind === nextKind && (nextKind === '[object Object]' || nextKind === '[object Array]') ) { // Diff nested object const entry = [ UNCHANGED + '\xa0\xa0'.repeat(indent) + key, nextKind === '[object Array]' ? 'Array' : '', ]; properties.push(entry); const prevLength = properties.length; const nestedEqual = addObjectDiffToProperties( prevValue, nextValue, properties, indent + 1, ); if (!nestedEqual) { isDeeplyEqual = false; } else if (prevLength === properties.length) { // Nothing notably changed inside the nested object. So this is only a change in reference // equality. Let's note it. entry[1] = 'Referentially unequal but deeply equal objects. Consider memoization.'; } continue; } } } else if ( typeof prevValue === 'function' && typeof nextValue === 'function' && prevValue.name === nextValue.name && prevValue.length === nextValue.length ) { // $FlowFixMe[method-unbinding] const prevSrc = Function.prototype.toString.call(prevValue); // $FlowFixMe[method-unbinding] const nextSrc = Function.prototype.toString.call(nextValue); if (prevSrc === nextSrc) { // This looks like it might be the same function but different closures. let desc; if (nextValue.name === '') { desc = '() => {}'; } else { desc = nextValue.name + '() {}'; } properties.push([ UNCHANGED + '\xa0\xa0'.repeat(indent) + key, desc + ' Referentially unequal function closure. Consider memoization.', ]); continue; } } // Otherwise, emit the change in property and the values. addValueToProperties(key, prevValue, properties, indent, REMOVED); addValueToProperties(key, nextValue, properties, indent, ADDED); isDeeplyEqual = false; } } else { properties.push([ADDED + '\xa0\xa0'.repeat(indent) + key, '\u2026']); isDeeplyEqual = false; } } return isDeeplyEqual; }