| 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 * as React from 'react'; |
| 11 | |
| 12 | import { |
| 13 | unstable_getCacheForType as getCacheForType, |
| 14 | startTransition, |
| 15 | } from 'react'; |
| 16 | import Store from 'react-devtools-shared/src/devtools/store'; |
| 17 | import {inspectElement as inspectElementMutableSource} from 'react-devtools-shared/src/inspectedElementMutableSource'; |
| 18 | import ElementPollingCancellationError from 'react-devtools-shared/src//errors/ElementPollingCancellationError'; |
| 19 | |
| 20 | import type {FrontendBridge} from 'react-devtools-shared/src/bridge'; |
| 21 | import type { |
| 22 | Thenable, |
| 23 | FulfilledThenable, |
| 24 | RejectedThenable, |
| 25 | } from 'shared/ReactTypes'; |
| 26 | import type { |
| 27 | Element, |
| 28 | InspectedElement as InspectedElementFrontend, |
| 29 | InspectedElementResponseType, |
| 30 | InspectedElementPath, |
| 31 | } from 'react-devtools-shared/src/frontend/types'; |
| 32 | |
| 33 | function readRecord<T>(record: Thenable<T>): T { |
| 34 | if (typeof React.use === 'function') { |
| 35 | // eslint-disable-next-line react-hooks-published/rules-of-hooks |
| 36 | return React.use(record); |
| 37 | } |
| 38 | if (record.status === 'fulfilled') { |
| 39 | return record.value; |
| 40 | } else if (record.status === 'rejected') { |
| 41 | throw record.reason; |
| 42 | } else { |
| 43 | throw record; |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | type InspectedElementMap = WeakMap<Element, Thenable<InspectedElementFrontend>>; |
| 48 | type CacheSeedKey = () => InspectedElementMap; |
| 49 | |
| 50 | function createMap(): InspectedElementMap { |
| 51 | return new WeakMap(); |
| 52 | } |
| 53 | |
| 54 | function getRecordMap(): WeakMap<Element, Thenable<InspectedElementFrontend>> { |
| 55 | return getCacheForType(createMap); |
| 56 | } |
| 57 | |
| 58 | function createCacheSeed( |
| 59 | element: Element, |
| 60 | inspectedElement: InspectedElementFrontend, |
| 61 | ): [CacheSeedKey, InspectedElementMap] { |
| 62 | const thenable: FulfilledThenable<InspectedElementFrontend> = { |
| 63 | then(callback: (value: any) => mixed, reject: (error: mixed) => mixed) { |
| 64 | callback(thenable.value); |
| 65 | }, |
| 66 | status: 'fulfilled', |
| 67 | value: inspectedElement, |
| 68 | }; |
| 69 | const map = createMap(); |
| 70 | map.set(element, thenable); |
| 71 | return [createMap, map]; |
| 72 | } |
| 73 | |
| 74 | /** |
| 75 | * Fetches element props and state from the backend for inspection. |
| 76 | * This method should be called during render; it will suspend if data has not yet been fetched. |
| 77 | */ |
| 78 | export function inspectElement( |
| 79 | element: Element, |
| 80 | path: InspectedElementPath | null, |
| 81 | store: Store, |
| 82 | bridge: FrontendBridge, |
| 83 | ): InspectedElementFrontend | null { |
| 84 | const map = getRecordMap(); |
| 85 | let record = map.get(element); |
| 86 | if (!record) { |
| 87 | const callbacks = new Set<(value: any) => mixed>(); |
| 88 | const rejectCallbacks = new Set<(reason: mixed) => mixed>(); |
| 89 | const thenable: Thenable<InspectedElementFrontend> = { |
| 90 | status: 'pending', |
| 91 | value: null, |
| 92 | reason: null, |
| 93 | then(callback: (value: any) => mixed, reject: (error: mixed) => mixed) { |
| 94 | callbacks.add(callback); |
| 95 | rejectCallbacks.add(reject); |
| 96 | }, |
| 97 | |
| 98 | // Optional property, read by React to name this I/O in async debug info: |
| 99 | displayName: `Inspecting ${element.displayName || 'Unknown'}`, |
| 100 | }; |
| 101 | |
| 102 | const wake = () => { |
| 103 | // This assumes they won't throw. |
| 104 | callbacks.forEach(callback => callback((thenable as any).value)); |
| 105 | callbacks.clear(); |
| 106 | rejectCallbacks.clear(); |
| 107 | }; |
| 108 | const wakeRejections = () => { |
| 109 | // This assumes they won't throw. |
| 110 | rejectCallbacks.forEach(callback => callback((thenable as any).reason)); |
| 111 | rejectCallbacks.clear(); |
| 112 | callbacks.clear(); |
| 113 | }; |
| 114 | record = thenable; |
| 115 | |
| 116 | const rendererID = store.getRendererIDForElement(element.id); |
| 117 | if (rendererID == null) { |
| 118 | const rejectedThenable: RejectedThenable<InspectedElementFrontend> = |
| 119 | thenable as any; |
| 120 | rejectedThenable.status = 'rejected'; |
| 121 | rejectedThenable.reason = new Error( |
| 122 | `Could not inspect element with id "${element.id}". No renderer found.`, |
| 123 | ); |
| 124 | |
| 125 | map.set(element, record); |
| 126 | |
| 127 | return null; |
| 128 | } |
| 129 | |
| 130 | inspectElementMutableSource(bridge, element, path, rendererID).then( |
| 131 | ([inspectedElement]: [ |
| 132 | InspectedElementFrontend, |
| 133 | InspectedElementResponseType, |
| 134 | ]) => { |
| 135 | const fulfilledThenable: FulfilledThenable<InspectedElementFrontend> = |
| 136 | thenable as any; |
| 137 | fulfilledThenable.status = 'fulfilled'; |
| 138 | fulfilledThenable.value = inspectedElement; |
| 139 | wake(); |
| 140 | }, |
| 141 | |
| 142 | error => { |
| 143 | console.error(error); |
| 144 | |
| 145 | const rejectedThenable: RejectedThenable<InspectedElementFrontend> = |
| 146 | thenable as any; |
| 147 | rejectedThenable.status = 'rejected'; |
| 148 | rejectedThenable.reason = error; |
| 149 | |
| 150 | wakeRejections(); |
| 151 | }, |
| 152 | ); |
| 153 | |
| 154 | map.set(element, record); |
| 155 | } |
| 156 | |
| 157 | const response = readRecord(record); |
| 158 | return response; |
| 159 | } |
| 160 | |
| 161 | type RefreshFunction = ( |
| 162 | seedKey: CacheSeedKey, |
| 163 | cacheMap: InspectedElementMap, |
| 164 | ) => void; |
| 165 | |
| 166 | /** |
| 167 | * Asks the backend for updated props and state from an expected element. |
| 168 | * This method should never be called during render; call it from an effect or event handler. |
| 169 | * This method will schedule an update if updated information is returned. |
| 170 | */ |
| 171 | export function checkForUpdate({ |
| 172 | bridge, |
| 173 | element, |
| 174 | refresh, |
| 175 | store, |
| 176 | }: { |
| 177 | bridge: FrontendBridge, |
| 178 | element: Element, |
| 179 | refresh: RefreshFunction, |
| 180 | store: Store, |
| 181 | }): void | Promise<void> { |
| 182 | const {id} = element; |
| 183 | const rendererID = store.getRendererIDForElement(id); |
| 184 | |
| 185 | if (rendererID == null) { |
| 186 | return; |
| 187 | } |
| 188 | |
| 189 | return inspectElementMutableSource( |
| 190 | bridge, |
| 191 | element, |
| 192 | null, |
| 193 | rendererID, |
| 194 | true, |
| 195 | ).then( |
| 196 | ([inspectedElement, responseType]: [ |
| 197 | InspectedElementFrontend, |
| 198 | InspectedElementResponseType, |
| 199 | ]) => { |
| 200 | if (responseType === 'full-data') { |
| 201 | startTransition(() => { |
| 202 | const [key, value] = createCacheSeed(element, inspectedElement); |
| 203 | refresh(key, value); |
| 204 | }); |
| 205 | } |
| 206 | }, |
| 207 | ); |
| 208 | } |
| 209 | |
| 210 | function createPromiseWhichResolvesInOneSecond() { |
| 211 | return new Promise(resolve => setTimeout(resolve, 1000)); |
| 212 | } |
| 213 | |
| 214 | type PollingStatus = 'idle' | 'running' | 'paused' | 'aborted'; |
| 215 | |
| 216 | export function startElementUpdatesPolling({ |
| 217 | bridge, |
| 218 | element, |
| 219 | refresh, |
| 220 | store, |
| 221 | }: { |
| 222 | bridge: FrontendBridge, |
| 223 | element: Element, |
| 224 | refresh: RefreshFunction, |
| 225 | store: Store, |
| 226 | }): {abort: () => void, pause: () => void, resume: () => void} { |
| 227 | let status: PollingStatus = 'idle'; |
| 228 | |
| 229 | function abort() { |
| 230 | status = 'aborted'; |
| 231 | } |
| 232 | |
| 233 | function resume() { |
| 234 | if (status === 'running' || status === 'aborted') { |
| 235 | return; |
| 236 | } |
| 237 | |
| 238 | status = 'idle'; |
| 239 | poll(); |
| 240 | } |
| 241 | |
| 242 | function pause() { |
| 243 | if (status === 'paused' || status === 'aborted') { |
| 244 | return; |
| 245 | } |
| 246 | |
| 247 | status = 'paused'; |
| 248 | } |
| 249 | |
| 250 | function poll(): Promise<void> { |
| 251 | status = 'running'; |
| 252 | |
| 253 | return Promise.allSettled([ |
| 254 | checkForUpdate({bridge, element, refresh, store}), |
| 255 | createPromiseWhichResolvesInOneSecond(), |
| 256 | ]) |
| 257 | .then(([{status: updateStatus, reason}]) => { |
| 258 | // There isn't much to do about errors in this case, |
| 259 | // but we should at least log them, so they aren't silent. |
| 260 | // Log only if polling is still active, we can't handle the case when |
| 261 | // request was sent, and then bridge was remounted (for example, when user did navigate to a new page), |
| 262 | // but at least we can mark that polling was aborted |
| 263 | if (updateStatus === 'rejected' && status !== 'aborted') { |
| 264 | // This is expected Promise rejection, no need to log it |
| 265 | if (reason instanceof ElementPollingCancellationError) { |
| 266 | return; |
| 267 | } |
| 268 | |
| 269 | console.error(reason); |
| 270 | } |
| 271 | }) |
| 272 | .finally(() => { |
| 273 | const shouldContinuePolling = |
| 274 | status !== 'aborted' && status !== 'paused'; |
| 275 | |
| 276 | status = 'idle'; |
| 277 | |
| 278 | if (shouldContinuePolling) { |
| 279 | return poll(); |
| 280 | } |
| 281 | }); |
| 282 | } |
| 283 | |
| 284 | poll(); |
| 285 | |
| 286 | return {abort, resume, pause}; |
| 287 | } |
| 288 | |
| 289 | export function clearCacheBecauseOfError(refresh: RefreshFunction): void { |
| 290 | startTransition(() => { |
| 291 | const map = createMap(); |
| 292 | refresh(createMap, map); |
| 293 | }); |
| 294 | } |