| 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 JSON5 from 'json5'; |
| 11 | |
| 12 | import type {ReactFunctionLocation} from 'shared/ReactTypes'; |
| 13 | import {ElementTypeActivity} from 'react-devtools-shared/src/frontend/types'; |
| 14 | import type { |
| 15 | Element, |
| 16 | SuspenseNode, |
| 17 | } from 'react-devtools-shared/src/frontend/types'; |
| 18 | import type {StateContext} from './views/Components/TreeContext'; |
| 19 | import type Store from './store'; |
| 20 | |
| 21 | export function printElement( |
| 22 | element: Element, |
| 23 | includeWeight: boolean = false, |
| 24 | ): string { |
| 25 | let prefix = ' '; |
| 26 | if (element.children.length > 0) { |
| 27 | prefix = element.isCollapsed ? '▸' : '▾'; |
| 28 | } |
| 29 | |
| 30 | let key = ''; |
| 31 | if (element.key !== null) { |
| 32 | key = ` key="${element.key}"`; |
| 33 | } |
| 34 | |
| 35 | let name = ''; |
| 36 | if (element.nameProp !== null) { |
| 37 | name = ` name="${element.nameProp}"`; |
| 38 | } |
| 39 | |
| 40 | let hocDisplayNames = null; |
| 41 | if (element.hocDisplayNames !== null) { |
| 42 | hocDisplayNames = [...element.hocDisplayNames]; |
| 43 | } |
| 44 | |
| 45 | const hocs = |
| 46 | hocDisplayNames === null ? '' : ` [${hocDisplayNames.join('][')}]`; |
| 47 | |
| 48 | let mode = ''; |
| 49 | if (element.type === ElementTypeActivity) { |
| 50 | mode = ` mode="${element.isActivityHidden ? 'hidden' : 'visible'}"`; |
| 51 | } |
| 52 | |
| 53 | let suffix = ''; |
| 54 | if (includeWeight) { |
| 55 | suffix = ` (${element.isCollapsed ? 1 : element.weight})`; |
| 56 | } |
| 57 | |
| 58 | return `${' '.repeat(element.depth + 1)}${prefix} <${ |
| 59 | element.displayName || 'null' |
| 60 | }${key}${name}${mode}>${hocs}${suffix}`; |
| 61 | } |
| 62 | |
| 63 | function printRects(rects: SuspenseNode['rects']): string { |
| 64 | if (rects === null) { |
| 65 | return ' rects={null}'; |
| 66 | } else { |
| 67 | return ` rects={[${rects.map(rect => `{x:${rect.x},y:${rect.y},width:${rect.width},height:${rect.height}}`).join(', ')}]}`; |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | function printSuspense(suspense: SuspenseNode): string { |
| 72 | const name = ` name="${suspense.name || 'Unknown'}"`; |
| 73 | const hasUniqueSuspenders = ` uniqueSuspenders={${suspense.hasUniqueSuspenders ? 'true' : 'false'}}`; |
| 74 | const printedRects = printRects(suspense.rects); |
| 75 | |
| 76 | return `<Suspense${name}${hasUniqueSuspenders}${printedRects}>`; |
| 77 | } |
| 78 | |
| 79 | function printSuspenseWithChildren( |
| 80 | store: Store, |
| 81 | suspense: SuspenseNode, |
| 82 | depth: number, |
| 83 | ): Array<string> { |
| 84 | const lines = [' '.repeat(depth) + printSuspense(suspense)]; |
| 85 | for (let i = 0; i < suspense.children.length; i++) { |
| 86 | const childID = suspense.children[i]; |
| 87 | const child = store.getSuspenseByID(childID); |
| 88 | if (child === null) { |
| 89 | throw new Error(`Could not find Suspense node with ID "${childID}".`); |
| 90 | } |
| 91 | lines.push(...printSuspenseWithChildren(store, child, depth + 1)); |
| 92 | } |
| 93 | |
| 94 | return lines; |
| 95 | } |
| 96 | |
| 97 | export function printOwnersList( |
| 98 | elements: Array<Element>, |
| 99 | includeWeight: boolean = false, |
| 100 | ): string { |
| 101 | return elements |
| 102 | .map(element => printElement(element, includeWeight)) |
| 103 | .join('\n'); |
| 104 | } |
| 105 | |
| 106 | export function printStore( |
| 107 | store: Store, |
| 108 | includeWeight: boolean = false, |
| 109 | state: StateContext | null = null, |
| 110 | includeSuspense: boolean = true, |
| 111 | ): string { |
| 112 | const snapshotLines = []; |
| 113 | |
| 114 | let rootWeight = 0; |
| 115 | |
| 116 | function printSelectedMarker(index: number): string { |
| 117 | if (state === null) { |
| 118 | return ''; |
| 119 | } |
| 120 | return state.inspectedElementIndex === index ? `→` : ' '; |
| 121 | } |
| 122 | |
| 123 | function printErrorsAndWarnings(element: Element): string { |
| 124 | const {errorCount, warningCount} = |
| 125 | store.getErrorAndWarningCountForElementID(element.id); |
| 126 | if (errorCount === 0 && warningCount === 0) { |
| 127 | return ''; |
| 128 | } |
| 129 | return ` ${errorCount > 0 ? '✕' : ''}${warningCount > 0 ? '⚠' : ''}`; |
| 130 | } |
| 131 | |
| 132 | const ownerFlatTree = state !== null ? state.ownerFlatTree : null; |
| 133 | if (ownerFlatTree !== null) { |
| 134 | snapshotLines.push( |
| 135 | '[owners]' + (includeWeight ? ` (${ownerFlatTree.length})` : ''), |
| 136 | ); |
| 137 | ownerFlatTree.forEach((element, index) => { |
| 138 | const printedSelectedMarker = printSelectedMarker(index); |
| 139 | const printedElement = printElement(element, false); |
| 140 | const printedErrorsAndWarnings = printErrorsAndWarnings(element); |
| 141 | snapshotLines.push( |
| 142 | `${printedSelectedMarker}${printedElement}${printedErrorsAndWarnings}`, |
| 143 | ); |
| 144 | }); |
| 145 | } else { |
| 146 | const errorsAndWarnings = store._errorsAndWarnings; |
| 147 | if (errorsAndWarnings.size > 0) { |
| 148 | let errorCount = 0; |
| 149 | let warningCount = 0; |
| 150 | errorsAndWarnings.forEach(entry => { |
| 151 | errorCount += entry.errorCount; |
| 152 | warningCount += entry.warningCount; |
| 153 | }); |
| 154 | |
| 155 | snapshotLines.push(`✕ ${errorCount}, ⚠ ${warningCount}`); |
| 156 | } |
| 157 | |
| 158 | store.roots.forEach(rootID => { |
| 159 | const {weight} = store.getElementByID(rootID) as any as Element; |
| 160 | const maybeWeightLabel = includeWeight ? ` (${weight})` : ''; |
| 161 | |
| 162 | // Store does not (yet) expose a way to get errors/warnings per root. |
| 163 | snapshotLines.push(`[root]${maybeWeightLabel}`); |
| 164 | |
| 165 | for (let i = rootWeight; i < rootWeight + weight; i++) { |
| 166 | const element = store.getElementAtIndex(i); |
| 167 | |
| 168 | if (element == null) { |
| 169 | throw Error(`Could not find element at index "${i}"`); |
| 170 | } |
| 171 | |
| 172 | const printedSelectedMarker = printSelectedMarker(i); |
| 173 | const printedElement = printElement(element, includeWeight); |
| 174 | const printedErrorsAndWarnings = printErrorsAndWarnings(element); |
| 175 | snapshotLines.push( |
| 176 | `${printedSelectedMarker}${printedElement}${printedErrorsAndWarnings}`, |
| 177 | ); |
| 178 | } |
| 179 | |
| 180 | rootWeight += weight; |
| 181 | |
| 182 | if (includeSuspense) { |
| 183 | const root = store.getSuspenseByID(rootID); |
| 184 | // Roots from legacy renderers don't have a separate Suspense tree |
| 185 | if (root !== null) { |
| 186 | if (root.children.length > 0) { |
| 187 | snapshotLines.push('[suspense-root] ' + printRects(root.rects)); |
| 188 | for (let i = 0; i < root.children.length; i++) { |
| 189 | const childID = root.children[i]; |
| 190 | const child = store.getSuspenseByID(childID); |
| 191 | if (child === null) { |
| 192 | throw new Error( |
| 193 | `Could not find Suspense node with ID "${childID}".`, |
| 194 | ); |
| 195 | } |
| 196 | snapshotLines.push(...printSuspenseWithChildren(store, child, 1)); |
| 197 | } |
| 198 | } |
| 199 | } |
| 200 | } |
| 201 | }); |
| 202 | |
| 203 | // Make sure the pretty-printed test align with the Store's reported number of total rows. |
| 204 | if (rootWeight !== store.numElements) { |
| 205 | throw Error( |
| 206 | `Inconsistent Store state. Individual root weights ("${rootWeight}") do not match total weight ("${store.numElements}")`, |
| 207 | ); |
| 208 | } |
| 209 | |
| 210 | // If roots have been unmounted, verify that they've been removed from maps. |
| 211 | // This helps ensure the Store doesn't leak memory. |
| 212 | store.assertExpectedRootMapSizes(); |
| 213 | } |
| 214 | |
| 215 | return snapshotLines.join('\n'); |
| 216 | } |
| 217 | |
| 218 | // We use JSON.parse to parse string values |
| 219 | // e.g. 'foo' is not valid JSON but it is a valid string |
| 220 | // so this method replaces e.g. 'foo' with "foo" |
| 221 | export function sanitizeForParse(value: any): any | string { |
| 222 | if (typeof value === 'string') { |
| 223 | if ( |
| 224 | value.length >= 2 && |
| 225 | value.charAt(0) === "'" && |
| 226 | value.charAt(value.length - 1) === "'" |
| 227 | ) { |
| 228 | return '"' + value.slice(1, value.length - 1) + '"'; |
| 229 | } |
| 230 | } |
| 231 | return value; |
| 232 | } |
| 233 | |
| 234 | export function smartParse(value: any): any | void | number { |
| 235 | switch (value) { |
| 236 | case 'Infinity': |
| 237 | return Infinity; |
| 238 | case '-Infinity': |
| 239 | return -Infinity; |
| 240 | case 'NaN': |
| 241 | return NaN; |
| 242 | case 'undefined': |
| 243 | return undefined; |
| 244 | default: |
| 245 | return JSON5.parse(sanitizeForParse(value)); |
| 246 | } |
| 247 | } |
| 248 | |
| 249 | export function smartStringify(value: any): string { |
| 250 | if (typeof value === 'number') { |
| 251 | if (Number.isNaN(value)) { |
| 252 | return 'NaN'; |
| 253 | } else if (!Number.isFinite(value)) { |
| 254 | return value > 0 ? 'Infinity' : '-Infinity'; |
| 255 | } |
| 256 | } else if (value === undefined) { |
| 257 | return 'undefined'; |
| 258 | } |
| 259 | |
| 260 | return JSON.stringify(value); |
| 261 | } |
| 262 | |
| 263 | const STACK_DELIMETER = /\n\s+at /; |
| 264 | const STACK_SOURCE_LOCATION = /([^\s]+) \((.+):(.+):(.+)\)/; |
| 265 | |
| 266 | export function stackToComponentLocations( |
| 267 | stack: string, |
| 268 | ): Array<[string, ?ReactFunctionLocation]> { |
| 269 | const out: Array<[string, ?ReactFunctionLocation]> = []; |
| 270 | stack |
| 271 | .split(STACK_DELIMETER) |
| 272 | .slice(1) |
| 273 | .forEach(entry => { |
| 274 | const match = STACK_SOURCE_LOCATION.exec(entry); |
| 275 | if (match) { |
| 276 | const [, component, url, row, column] = match; |
| 277 | out.push([ |
| 278 | component, |
| 279 | [component, url, parseInt(row, 10), parseInt(column, 10)], |
| 280 | ]); |
| 281 | } else { |
| 282 | out.push([entry, null]); |
| 283 | } |
| 284 | }); |
| 285 | return out; |
| 286 | } |