/** * 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 JSON5 from 'json5'; import type {ReactFunctionLocation} from 'shared/ReactTypes'; import type { Element, SuspenseNode, } from 'react-devtools-shared/src/frontend/types'; import type {StateContext} from './views/Components/TreeContext'; import type Store from './store'; export function printElement( element: Element, includeWeight: boolean = false, ): string { let prefix = ' '; if (element.children.length > 0) { prefix = element.isCollapsed ? '▸' : '▾'; } let key = ''; if (element.key !== null) { key = ` key="${element.key}"`; } let name = ''; if (element.nameProp !== null) { name = ` name="${element.nameProp}"`; } let hocDisplayNames = null; if (element.hocDisplayNames !== null) { hocDisplayNames = [...element.hocDisplayNames]; } const hocs = hocDisplayNames === null ? '' : ` [${hocDisplayNames.join('][')}]`; let suffix = ''; if (includeWeight) { suffix = ` (${element.isCollapsed ? 1 : element.weight})`; } return `${' '.repeat(element.depth + 1)}${prefix} <${ element.displayName || 'null' }${key}${name}>${hocs}${suffix}`; } function printRects(rects: SuspenseNode['rects']): string { if (rects === null) { return ' rects={null}'; } else { return ` rects={[${rects.map(rect => `{x:${rect.x},y:${rect.y},width:${rect.width},height:${rect.height}}`).join(', ')}]}`; } } function printSuspense(suspense: SuspenseNode): string { let name = ''; if (suspense.name !== null) { name = ` name="${suspense.name}"`; } const printedRects = printRects(suspense.rects); return ``; } function printSuspenseWithChildren( store: Store, suspense: SuspenseNode, depth: number, ): Array { const lines = [' '.repeat(depth) + printSuspense(suspense)]; for (let i = 0; i < suspense.children.length; i++) { const childID = suspense.children[i]; const child = store.getSuspenseByID(childID); if (child === null) { throw new Error(`Could not find Suspense node with ID "${childID}".`); } lines.push(...printSuspenseWithChildren(store, child, depth + 1)); } return lines; } export function printOwnersList( elements: Array, includeWeight: boolean = false, ): string { return elements .map(element => printElement(element, includeWeight)) .join('\n'); } export function printStore( store: Store, includeWeight: boolean = false, state: StateContext | null = null, includeSuspense: boolean = true, ): string { const snapshotLines = []; let rootWeight = 0; function printSelectedMarker(index: number): string { if (state === null) { return ''; } return state.inspectedElementIndex === index ? `→` : ' '; } function printErrorsAndWarnings(element: Element): string { const {errorCount, warningCount} = store.getErrorAndWarningCountForElementID(element.id); if (errorCount === 0 && warningCount === 0) { return ''; } return ` ${errorCount > 0 ? '✕' : ''}${warningCount > 0 ? '⚠' : ''}`; } const ownerFlatTree = state !== null ? state.ownerFlatTree : null; if (ownerFlatTree !== null) { snapshotLines.push( '[owners]' + (includeWeight ? ` (${ownerFlatTree.length})` : ''), ); ownerFlatTree.forEach((element, index) => { const printedSelectedMarker = printSelectedMarker(index); const printedElement = printElement(element, false); const printedErrorsAndWarnings = printErrorsAndWarnings(element); snapshotLines.push( `${printedSelectedMarker}${printedElement}${printedErrorsAndWarnings}`, ); }); } else { const errorsAndWarnings = store._errorsAndWarnings; if (errorsAndWarnings.size > 0) { let errorCount = 0; let warningCount = 0; errorsAndWarnings.forEach(entry => { errorCount += entry.errorCount; warningCount += entry.warningCount; }); snapshotLines.push(`✕ ${errorCount}, ⚠ ${warningCount}`); } store.roots.forEach(rootID => { const {weight} = ((store.getElementByID(rootID): any): Element); const maybeWeightLabel = includeWeight ? ` (${weight})` : ''; // Store does not (yet) expose a way to get errors/warnings per root. snapshotLines.push(`[root]${maybeWeightLabel}`); for (let i = rootWeight; i < rootWeight + weight; i++) { const element = store.getElementAtIndex(i); if (element == null) { throw Error(`Could not find element at index "${i}"`); } const printedSelectedMarker = printSelectedMarker(i); const printedElement = printElement(element, includeWeight); const printedErrorsAndWarnings = printErrorsAndWarnings(element); snapshotLines.push( `${printedSelectedMarker}${printedElement}${printedErrorsAndWarnings}`, ); } rootWeight += weight; if (includeSuspense) { const root = store.getSuspenseByID(rootID); // Roots from legacy renderers don't have a separate Suspense tree if (root !== null) { if (root.children.length > 0) { snapshotLines.push('[suspense-root] ' + printRects(root.rects)); for (let i = 0; i < root.children.length; i++) { const childID = root.children[i]; const child = store.getSuspenseByID(childID); if (child === null) { throw new Error( `Could not find Suspense node with ID "${childID}".`, ); } snapshotLines.push(...printSuspenseWithChildren(store, child, 1)); } } } } }); // Make sure the pretty-printed test align with the Store's reported number of total rows. if (rootWeight !== store.numElements) { throw Error( `Inconsistent Store state. Individual root weights ("${rootWeight}") do not match total weight ("${store.numElements}")`, ); } // If roots have been unmounted, verify that they've been removed from maps. // This helps ensure the Store doesn't leak memory. store.assertExpectedRootMapSizes(); } return snapshotLines.join('\n'); } // We use JSON.parse to parse string values // e.g. 'foo' is not valid JSON but it is a valid string // so this method replaces e.g. 'foo' with "foo" export function sanitizeForParse(value: any): any | string { if (typeof value === 'string') { if ( value.length >= 2 && value.charAt(0) === "'" && value.charAt(value.length - 1) === "'" ) { return '"' + value.slice(1, value.length - 1) + '"'; } } return value; } export function smartParse(value: any): any | void | number { switch (value) { case 'Infinity': return Infinity; case 'NaN': return NaN; case 'undefined': return undefined; default: return JSON5.parse(sanitizeForParse(value)); } } export function smartStringify(value: any): string { if (typeof value === 'number') { if (Number.isNaN(value)) { return 'NaN'; } else if (!Number.isFinite(value)) { return 'Infinity'; } } else if (value === undefined) { return 'undefined'; } return JSON.stringify(value); } const STACK_DELIMETER = /\n\s+at /; const STACK_SOURCE_LOCATION = /([^\s]+) \((.+):(.+):(.+)\)/; export function stackToComponentLocations( stack: string, ): Array<[string, ?ReactFunctionLocation]> { const out: Array<[string, ?ReactFunctionLocation]> = []; stack .split(STACK_DELIMETER) .slice(1) .forEach(entry => { const match = STACK_SOURCE_LOCATION.exec(entry); if (match) { const [, component, url, row, column] = match; out.push([ component, [component, url, parseInt(row, 10), parseInt(column, 10)], ]); } else { out.push([entry, null]); } }); return out; }