diff --git a/.eslintrc.js b/.eslintrc.js index 2c9ad7a4c925d..c81161ae67c28 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -609,6 +609,7 @@ module.exports = { SyntheticEvent: 'readonly', SyntheticMouseEvent: 'readonly', SyntheticPointerEvent: 'readonly', + SyntheticKeyboardEvent: 'readonly', Thenable: 'readonly', TimeoutID: 'readonly', WheelEventHandler: 'readonly', diff --git a/packages/react-devtools-shared/src/devtools/store.js b/packages/react-devtools-shared/src/devtools/store.js index 622c9a475419c..0b30f363df440 100644 --- a/packages/react-devtools-shared/src/devtools/store.js +++ b/packages/react-devtools-shared/src/devtools/store.js @@ -200,6 +200,7 @@ export default class Store extends EventEmitter<{ // Total number of visible elements (within all roots). // Used for windowing purposes. _weightAcrossRoots: number = 0; + _weightAcrossRootsSuspense: number = 0; _shouldCheckBridgeProtocolCompatibility: boolean = false; _hookSettings: $ReadOnly | null = null; @@ -296,6 +297,7 @@ export default class Store extends EventEmitter<{ if (this.roots.length === 0) { // The only safe time to assert these maps are empty is when the store is empty. this.assertMapSizeMatchesRootCount(this._idToElement, '_idToElement'); + this.assertMapSizeMatchesRootCount(this._idToSuspense, '_idToSuspense'); this.assertMapSizeMatchesRootCount(this._ownersMap, '_ownersMap'); } @@ -428,6 +430,10 @@ export default class Store extends EventEmitter<{ return this._weightAcrossRoots; } + get numSuspense(): number { + return this._weightAcrossRootsSuspense; + } + get profilerStore(): ProfilerStore { return this._profilerStore; } @@ -608,6 +614,88 @@ export default class Store extends EventEmitter<{ return element; } + getSuspenseAtIndex(index: number): SuspenseNode | null { + if (index < 0 || index >= this.numElements) { + console.warn( + `Invalid index ${index} specified; store contains ${this.numElements} items.`, + ); + + return null; + } + + // Find which root this suspense is in... + let root; + let rootWeight = 0; + for (let i = 0; i < this._roots.length; i++) { + const rootID = this._roots[i]; + root = this._idToSuspense.get(rootID); + + if (root === undefined) { + this._throwAndEmitError( + Error( + `Couldn't find root with id "${rootID}": no matching suspense node was found in the Store.`, + ), + ); + + return null; + } + + if (root.children.length === 0) { + continue; + } + + if (rootWeight + root.weight > index) { + break; + } else { + rootWeight += root.weight; + } + } + + if (root === undefined) { + return null; + } + + // Find the suspense in the tree using the weight of each node... + // Skip over the root itself, because shells aren't visible in the Suspense tree. + let currentSuspense: SuspenseNode = root; + let currentWeight = rootWeight - 1; + + while (index !== currentWeight) { + const numChildren = currentSuspense.children.length; + for (let i = 0; i < numChildren; i++) { + const childID = currentSuspense.children[i]; + const child = this._idToSuspense.get(childID); + + if (child === undefined) { + this._throwAndEmitError( + Error( + `Couldn't find child suspense with id "${childID}": no matching node was found in the Store.`, + ), + ); + + return null; + } + + const childWeight = child.weight; + + if (index <= currentWeight + childWeight) { + currentWeight++; + currentSuspense = child; + break; + } else { + currentWeight += childWeight; + } + } + } + + return currentSuspense || null; + } + + getSuspenseIDAtIndex(index: number): number | null { + const suspense = this.getSuspenseAtIndex(index); + return suspense === null ? null : suspense.id; + } + getSuspenseByID(id: SuspenseNode['id']): SuspenseNode | null { const suspense = this._idToSuspense.get(id); if (suspense === undefined) { @@ -618,6 +706,22 @@ export default class Store extends EventEmitter<{ return suspense; } + getNearestSuspense(elementID: Element['id']): SuspenseNode | null { + let currentID = elementID; + let maybeSuspense = this._idToSuspense.get(currentID); + while (maybeSuspense === undefined) { + const element = this._idToElement.get(currentID); + if (element === undefined) { + return null; + } + + currentID = element.parentID; + maybeSuspense = this._idToSuspense.get(currentID); + } + + return maybeSuspense; + } + // Returns a tuple of [id, index] getElementsWithErrorsAndWarnings(): ErrorAndWarningTuples { if (!this._shouldShowWarningsAndErrors) { @@ -729,6 +833,71 @@ export default class Store extends EventEmitter<{ return index; } + getIndexOfSuspenseID(id: number): number | null { + const suspense = this.getSuspenseByID(id); + + if (suspense === null || suspense.parentID === 0) { + return null; + } + + // Walk up the tree to the root. + // Increment the index by one for each node we encounter, + // and by the weight of all nodes to the left of the current one. + // This should be a relatively fast way of determining the index of a node within the tree. + let previousID = id; + let currentID = suspense.parentID; + let index = 0; + while (true) { + const current = this._idToSuspense.get(currentID); + if (current === undefined) { + return null; + } + + const {children} = current; + for (let i = 0; i < children.length; i++) { + const childID = children[i]; + if (childID === previousID) { + break; + } + + const child = this._idToSuspense.get(childID); + if (child === undefined) { + return null; + } + + index += child.weight; + } + + if (current.parentID === 0) { + // We found the root; stop crawling. + break; + } + + index++; + + previousID = current.id; + currentID = current.parentID; + } + + // At this point, the current ID is a root (from the previous loop). + // We also need to offset the index by previous root weights. + for (let i = 0; i < this._roots.length; i++) { + const rootID = this._roots[i]; + if (rootID === currentID) { + break; + } + + const root = this._idToSuspense.get(rootID); + if (root === undefined) { + return null; + } + + index += root.weight; + } + + return index; + } + isDescendantOf(parentId: number, descendantId: number): boolean { if (descendantId === 0) { return false; @@ -980,6 +1149,19 @@ export default class Store extends EventEmitter<{ } }; + _adjustParentSuspenseTreeWeight: ( + parentElement: ?SuspenseNode, + weightDelta: number, + ) => void = (parentElement, weightDelta) => { + while (parentElement != null) { + parentElement.weight += weightDelta; + + parentElement = this._idToSuspense.get(parentElement.parentID); + } + + this._weightAcrossRootsSuspense += weightDelta; + }; + _recursivelyUpdateSubtree( id: number, callback: (element: Element) => void, @@ -1293,6 +1475,7 @@ export default class Store extends EventEmitter<{ const recursivelyDeleteElements = (elementID: number) => { const element = this._idToElement.get(elementID); this._idToElement.delete(elementID); + this._idToSuspense.delete(elementID); if (element) { // Mostly for Flow's sake for (let index = 0; index < element.children.length; index++) { @@ -1312,12 +1495,22 @@ export default class Store extends EventEmitter<{ break; } + const suspenseNode = this._idToSuspense.get(id); + if (suspenseNode === undefined) { + this._throwAndEmitError( + Error(`Root "${id}" has no Suspense node.`), + ); + + break; + } + recursivelyDeleteElements(id); this._rootIDToCapabilities.delete(id); this._rootIDToRendererID.delete(id); this._roots = this._roots.filter(rootID => rootID !== id); this._weightAcrossRoots -= root.weight; + this._weightAcrossRootsSuspense -= suspenseNode.weight; break; } case TREE_OPERATION_REORDER_CHILDREN: { @@ -1422,6 +1615,7 @@ export default class Store extends EventEmitter<{ ); } + let isRoot; const element = this._idToElement.get(id); if (element === undefined) { this._throwAndEmitError( @@ -1440,14 +1634,17 @@ export default class Store extends EventEmitter<{ name = `${owner.displayName || 'Unknown'}>?`; } } + + isRoot = element.type === ElementTypeRoot; } if (__DEBUG__) { debug('Suspense Add', `node ${id} as child of ${parentID}`); } + let parentSuspense: ?SuspenseNode = null; if (parentID !== 0) { - const parentSuspense = this._idToSuspense.get(parentID); + parentSuspense = this._idToSuspense.get(parentID); if (parentSuspense === undefined) { this._throwAndEmitError( Error( @@ -1465,12 +1662,17 @@ export default class Store extends EventEmitter<{ name = 'Unknown'; } + const weight = isRoot ? 0 : 1; this._idToSuspense.set(id, { id, parentID, children: [], name, + weight, }); + if (!isRoot) { + this._adjustParentSuspenseTreeWeight(parentSuspense, 1); + } i += 4; @@ -1497,7 +1699,7 @@ export default class Store extends EventEmitter<{ i += 1; - const {children, parentID} = suspense; + const {children, parentID, weight} = suspense; if (children.length > 0) { this._throwAndEmitError( Error(`Suspense node "${id}" was removed before its children.`), @@ -1530,6 +1732,8 @@ export default class Store extends EventEmitter<{ const index = parentSuspense.children.indexOf(id); parentSuspense.children.splice(index, 1); } + + this._adjustParentSuspenseTreeWeight(parentSuspense, -weight); } hasSuspenseTreeChanged = true; diff --git a/packages/react-devtools-shared/src/devtools/utils.js b/packages/react-devtools-shared/src/devtools/utils.js index 8ce34bf61175c..c43f1742ea2b7 100644 --- a/packages/react-devtools-shared/src/devtools/utils.js +++ b/packages/react-devtools-shared/src/devtools/utils.js @@ -10,7 +10,10 @@ import JSON5 from 'json5'; import type {ReactFunctionLocation} from 'shared/ReactTypes'; -import type {Element} from 'react-devtools-shared/src/frontend/types'; +import type { + Element, + SuspenseNode, +} from 'react-devtools-shared/src/frontend/types'; import type {StateContext} from './views/Components/TreeContext'; import type Store from './store'; @@ -46,6 +49,26 @@ export function printElement( }${key}>${hocs}${suffix}`; } +function printSuspense( + suspense: SuspenseNode, + includeWeight: boolean = false, +): string { + let name = ''; + if (suspense.name !== null) { + name = ` name="${suspense.name}"`; + } + + // TODO: Use actual rects. + const printedRects = ' rects={?,?,?,?}'; + + let suffix = ''; + if (includeWeight) { + suffix = ` (${suspense.weight})`; + } + + return ` ${suffix}`; +} + export function printOwnersList( elements: Array, includeWeight: boolean = false, @@ -63,6 +86,7 @@ export function printStore( const snapshotLines = []; let rootWeight = 0; + let rootWeightSuspense = 0; function printSelectedMarker(index: number): string { if (state === null) { @@ -128,7 +152,34 @@ export function printStore( ); } + const {weight: weightSuspense} = ((store.getSuspenseByID( + rootID, + ): any): SuspenseNode); + const maybeWeightLabelSuspense = includeWeight + ? ` (${weightSuspense})` + : ''; + + snapshotLines.push(`[shell]${maybeWeightLabelSuspense}`); + + for ( + let i = rootWeightSuspense; + i < rootWeightSuspense + weightSuspense; + i++ + ) { + const suspense = store.getSuspenseAtIndex(i); + + if (suspense == null) { + throw Error(`Could not find suspense at index "${i}"`); + } + + const printedSelectedMarker = + state !== null && state.selectedSuspenseIndex === i ? `→` : ' '; + const printedElement = printSuspense(suspense, includeWeight); + snapshotLines.push(`${printedSelectedMarker}${printedElement}`); + } + rootWeight += weight; + rootWeightSuspense += weightSuspense; }); // Make sure the pretty-printed test align with the Store's reported number of total rows. @@ -137,6 +188,12 @@ export function printStore( `Inconsistent Store state. Individual root weights ("${rootWeight}") do not match total weight ("${store.numElements}")`, ); } + // Make sure the pretty-printed test align with the Store's reported number of total rows. + if (rootWeightSuspense !== store.numSuspense) { + throw Error( + `Inconsistent Suspense Store state. Individual root weights ("${rootWeightSuspense}") do not match total weight ("${store.numSuspense}")`, + ); + } // If roots have been unmounted, verify that they've been removed from maps. // This helps ensure the Store doesn't leak memory. diff --git a/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js b/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js index f43ced82447ef..f628f36018bdf 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js @@ -60,6 +60,11 @@ export type StateContext = { // Inspection element panel inspectedElementID: number | null, inspectedElementIndex: number | null, + + // Suspense + numSuspense: number, + selectedSuspenseID: number | null, + selectedSuspenseIndex: number | null, }; type ACTION_GO_TO_NEXT_SEARCH_RESULT = { @@ -121,6 +126,12 @@ type ACTION_SET_SEARCH_TEXT = { type: 'SET_SEARCH_TEXT', payload: string, }; +type ACTION_SELECT_PREVIOUS_SUSPENSE_IN_TREE = { + type: 'SELECT_PREVIOUS_SUSPENSE_IN_TREE', +}; +type ACTION_SELECT_NEXT_SUSPENSE_IN_TREE = { + type: 'SELECT_NEXT_SUSPENSE_IN_TREE', +}; type Action = | ACTION_GO_TO_NEXT_SEARCH_RESULT @@ -140,7 +151,9 @@ type Action = | ACTION_SELECT_PREVIOUS_SIBLING_IN_TREE | ACTION_SELECT_OWNER_LIST_NEXT_ELEMENT_IN_TREE | ACTION_SELECT_OWNER_LIST_PREVIOUS_ELEMENT_IN_TREE - | ACTION_SET_SEARCH_TEXT; + | ACTION_SET_SEARCH_TEXT + | ACTION_SELECT_PREVIOUS_SUSPENSE_IN_TREE + | ACTION_SELECT_NEXT_SUSPENSE_IN_TREE; export type DispatcherContext = (action: Action) => void; @@ -170,6 +183,11 @@ type State = { // Inspection element panel inspectedElementID: number | null, inspectedElementIndex: number | null, + + // Suspense + numSuspense: number, + selectedSuspenseID: number | null, + selectedSuspenseIndex: number | null, }; function reduceTreeState(store: Store, state: State, action: Action): State { @@ -178,6 +196,9 @@ function reduceTreeState(store: Store, state: State, action: Action): State { ownerSubtreeLeafElementID, inspectedElementID, inspectedElementIndex, + selectedSuspenseID, + selectedSuspenseIndex, + numSuspense, } = state; const ownerID = state.ownerID; @@ -188,6 +209,7 @@ function reduceTreeState(store: Store, state: State, action: Action): State { switch (action.type) { case 'HANDLE_STORE_MUTATION': numElements = store.numElements; + numSuspense = store.numSuspense; // If the currently-selected Element has been removed from the tree, update selection state. const removedIDs = action.payload[1]; @@ -447,6 +469,37 @@ function reduceTreeState(store: Store, state: State, action: Action): State { lookupIDForIndex = false; break; } + case 'SELECT_NEXT_SUSPENSE_IN_TREE': { + if ( + selectedSuspenseIndex === null || + selectedSuspenseIndex + 1 >= numSuspense + ) { + selectedSuspenseIndex = 0; + } else { + selectedSuspenseIndex++; + } + selectedSuspenseID = store.getSuspenseIDAtIndex(selectedSuspenseIndex); + inspectedElementID = selectedSuspenseID; + inspectedElementIndex = + inspectedElementID === null + ? null + : store.getIndexOfElementID(inspectedElementID); + break; + } + case 'SELECT_PREVIOUS_SUSPENSE_IN_TREE': { + if (selectedSuspenseIndex === null || selectedSuspenseIndex === 0) { + selectedSuspenseIndex = numSuspense - 1; + } else { + selectedSuspenseIndex--; + } + selectedSuspenseID = store.getSuspenseIDAtIndex(selectedSuspenseIndex); + inspectedElementID = selectedSuspenseID; + inspectedElementIndex = + inspectedElementID === null + ? null + : store.getIndexOfElementID(inspectedElementID); + break; + } default: // React can bailout of no-op updates. return state; @@ -474,6 +527,10 @@ function reduceTreeState(store: Store, state: State, action: Action): State { ownerSubtreeLeafElementID, inspectedElementIndex, inspectedElementID, + + numSuspense, + selectedSuspenseID, + selectedSuspenseIndex, }; } @@ -794,6 +851,36 @@ function reduceOwnersState(store: Store, state: State, action: Action): State { }; } +function reduceSuspenseTreeState( + store: Store, + previousState: State, + state: State, +): State { + let {selectedSuspenseID, selectedSuspenseIndex} = state; + const {inspectedElementID} = state; + if ( + previousState.inspectedElementID !== inspectedElementID && + inspectedElementID !== selectedSuspenseID + ) { + // We changed the inspected element. Select the nearest Suspense + // boundary of the newly inspected element. + if (inspectedElementID !== null) { + const nearestSuspense = store.getNearestSuspense(inspectedElementID); + if (nearestSuspense !== null) { + selectedSuspenseID = nearestSuspense.id; + selectedSuspenseIndex = store.getIndexOfSuspenseID(selectedSuspenseID); + } + } else { + selectedSuspenseID = null; + selectedSuspenseIndex = null; + } + + return {...state, selectedSuspenseID, selectedSuspenseIndex}; + } + + return state; +} + type Props = { children: React$Node, @@ -814,6 +901,14 @@ function getInitialState({ defaultInspectedElementIndex?: ?number, store: Store, }): State { + const inspectedElementID = + defaultInspectedElementID != null + ? defaultInspectedElementID + : store.lastSelectedHostInstanceElementId; + + // TODO: + const selectedSuspenseID = null; + const selectedSuspenseIndex = null; return { // Tree numElements: store.numElements, @@ -829,16 +924,18 @@ function getInitialState({ ownerFlatTree: null, // Inspection element panel - inspectedElementID: - defaultInspectedElementID != null - ? defaultInspectedElementID - : store.lastSelectedHostInstanceElementId, + inspectedElementID: inspectedElementID, inspectedElementIndex: defaultInspectedElementIndex != null ? defaultInspectedElementIndex : store.lastSelectedHostInstanceElementId ? store.getIndexOfElementID(store.lastSelectedHostInstanceElementId) : null, + + // Suspense + numSuspense: store.numSuspense, + selectedSuspenseID: selectedSuspenseID, + selectedSuspenseIndex: selectedSuspenseIndex, }; } @@ -858,7 +955,7 @@ function TreeContextController({ // so it's okay for the reducer to have an empty dependencies array. const reducer = useMemo( () => - (state: State, action: Action): State => { + (previousState: State, action: Action): State => { const {type} = action; switch (type) { case 'GO_TO_NEXT_SEARCH_RESULT': @@ -879,9 +976,13 @@ function TreeContextController({ case 'SELECT_PREVIOUS_SIBLING_IN_TREE': case 'SELECT_OWNER': case 'SET_SEARCH_TEXT': + case 'SELECT_PREVIOUS_SUSPENSE_IN_TREE': + case 'SELECT_NEXT_SUSPENSE_IN_TREE': + let state = previousState; state = reduceTreeState(store, state, action); state = reduceSearchState(store, state, action); state = reduceOwnersState(store, state, action); + state = reduceSuspenseTreeState(store, previousState, state); // TODO(hoxyq): review // If the selected ID is in a collapsed subtree, reset the selected index to null. diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js index d113fd3901f0c..211313703c75a 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js @@ -341,6 +341,7 @@ function SuspenseTab(_: {}) {