diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index f190f2704d77e..12e2ce31fb16f 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -88,6 +88,7 @@ import { SUSPENSE_TREE_OPERATION_REMOVE, SUSPENSE_TREE_OPERATION_REORDER_CHILDREN, SUSPENSE_TREE_OPERATION_RESIZE, + SUSPENSE_TREE_OPERATION_SUSPENDERS, UNKNOWN_SUSPENDERS_NONE, UNKNOWN_SUSPENDERS_REASON_PRODUCTION, UNKNOWN_SUSPENDERS_REASON_OLD_VERSION, @@ -2016,6 +2017,7 @@ export function attach( const pendingOperations: OperationsArray = []; const pendingRealUnmountedIDs: Array = []; const pendingRealUnmountedSuspenseIDs: Array = []; + const pendingSuspenderChanges: Set = new Set(); let pendingOperationsQueue: Array | null = []; const pendingStringTable: Map = new Map(); let pendingStringTableLength: number = 0; @@ -2047,6 +2049,7 @@ export function attach( pendingOperations.length === 0 && pendingRealUnmountedIDs.length === 0 && pendingRealUnmountedSuspenseIDs.length === 0 && + pendingSuspenderChanges.size === 0 && pendingUnmountedRootID === null ); } @@ -2113,6 +2116,7 @@ export function attach( pendingRealUnmountedIDs.length + (pendingUnmountedRootID === null ? 0 : 1); const numUnmountSuspenseIDs = pendingRealUnmountedSuspenseIDs.length; + const numSuspenderChanges = pendingSuspenderChanges.size; const operations = new Array( // Identify which renderer this update is coming from. @@ -2128,7 +2132,10 @@ export function attach( // [TREE_OPERATION_REMOVE, removedIDLength, ...ids] (numUnmountIDs > 0 ? 2 + numUnmountIDs : 0) + // Regular operations - pendingOperations.length, + pendingOperations.length + + // All suspender changes are batched in a single message. + // [SUSPENSE_TREE_OPERATION_SUSPENDERS, suspenderChangesLength, ...[id, hasUniqueSuspenders]] + (numSuspenderChanges > 0 ? 2 + numSuspenderChanges * 2 : 0), ); // Identify which renderer this update is coming from. @@ -2191,12 +2198,31 @@ export function attach( i++; } } - // Fill in the rest of the operations. + + // Fill in pending operations. for (let j = 0; j < pendingOperations.length; j++) { operations[i + j] = pendingOperations[j]; } i += pendingOperations.length; + // Suspender changes might affect newly mounted nodes that we already recorded + // in pending operations. + if (numSuspenderChanges > 0) { + operations[i++] = SUSPENSE_TREE_OPERATION_SUSPENDERS; + operations[i++] = numSuspenderChanges; + pendingSuspenderChanges.forEach(fiberIdWithChanges => { + const suspense = idToSuspenseNodeMap.get(fiberIdWithChanges); + if (suspense === undefined) { + // Probably forgot to cleanup pendingSuspenderChanges when this node was removed. + throw new Error( + `Could not send suspender changes for "${fiberIdWithChanges}" since the Fiber no longer exists.`, + ); + } + operations[i++] = fiberIdWithChanges; + operations[i++] = suspense.hasUniqueSuspenders ? 1 : 0; + }); + } + // Let the frontend know about tree operations. flushOrQueueOperations(operations); @@ -2204,6 +2230,7 @@ export function attach( pendingOperations.length = 0; pendingRealUnmountedIDs.length = 0; pendingRealUnmountedSuspenseIDs.length = 0; + pendingSuspenderChanges.clear(); pendingUnmountedRootID = null; pendingStringTable.clear(); pendingStringTableLength = 0; @@ -2688,6 +2715,19 @@ export function attach( } } + function recordSuspenseSuspenders(suspenseNode: SuspenseNode): void { + if (__DEBUG__) { + console.log('recordSuspenseSuspenders()', suspenseNode); + } + const fiberInstance = suspenseNode.instance; + if (fiberInstance.kind !== FIBER_INSTANCE) { + // TODO: Suspender updates of filtered Suspense nodes are currently dropped. + return; + } + + pendingSuspenderChanges.add(fiberInstance.id); + } + function recordSuspenseUnmount(suspenseInstance: SuspenseNode): void { if (__DEBUG__) { console.log( @@ -2709,6 +2749,7 @@ export function attach( // and later arrange them in the correct order. pendingRealUnmountedSuspenseIDs.push(id); + pendingSuspenderChanges.delete(id); idToSuspenseNodeMap.delete(id); } @@ -2779,6 +2820,7 @@ export function attach( ) { // This didn't exist in the parent before, so let's mark this boundary as having a unique suspender. parentSuspenseNode.hasUniqueSuspenders = true; + recordSuspenseSuspenders(parentSuspenseNode); } } // We have observed at least one known reason this might have been suspended. @@ -2820,6 +2862,9 @@ export function attach( // We have found a child boundary that depended on the unblocked I/O. // It can now be marked as having unique suspenders. We can skip its children // since they'll still be blocked by this one. + if (!node.hasUniqueSuspenders) { + recordSuspenseSuspenders(node); + } node.hasUniqueSuspenders = true; node.hasUnknownSuspenders = false; } else if (node.firstChild !== null) { @@ -3522,6 +3567,9 @@ export function attach( // Unfortunately if we don't have any DEV time debug info or debug thenables then // we have no meta data to show. However, we still mark this Suspense boundary as // participating in the loading sequence since apparently it can suspend. + if (!suspenseNode.hasUniqueSuspenders) { + recordSuspenseSuspenders(suspenseNode); + } suspenseNode.hasUniqueSuspenders = true; // We have not seen any reason yet for why this suspense node might have been // suspended but it clearly has been at some point. If we later discover a reason diff --git a/packages/react-devtools-shared/src/constants.js b/packages/react-devtools-shared/src/constants.js index 8071d3d4a2c6a..1a8ef8caa8b14 100644 --- a/packages/react-devtools-shared/src/constants.js +++ b/packages/react-devtools-shared/src/constants.js @@ -28,6 +28,7 @@ export const SUSPENSE_TREE_OPERATION_ADD = 8; export const SUSPENSE_TREE_OPERATION_REMOVE = 9; export const SUSPENSE_TREE_OPERATION_REORDER_CHILDREN = 10; export const SUSPENSE_TREE_OPERATION_RESIZE = 11; +export const SUSPENSE_TREE_OPERATION_SUSPENDERS = 12; export const PROFILING_FLAG_BASIC_SUPPORT = 0b01; export const PROFILING_FLAG_TIMELINE_SUPPORT = 0b10; diff --git a/packages/react-devtools-shared/src/devtools/store.js b/packages/react-devtools-shared/src/devtools/store.js index 310321b5ffcc5..bb540f09daf30 100644 --- a/packages/react-devtools-shared/src/devtools/store.js +++ b/packages/react-devtools-shared/src/devtools/store.js @@ -24,6 +24,7 @@ import { SUSPENSE_TREE_OPERATION_REMOVE, SUSPENSE_TREE_OPERATION_REORDER_CHILDREN, SUSPENSE_TREE_OPERATION_RESIZE, + SUSPENSE_TREE_OPERATION_SUSPENDERS, } from '../constants'; import {ElementTypeRoot} from '../frontend/types'; import { @@ -111,7 +112,7 @@ export default class Store extends EventEmitter<{ roots: [], rootSupportsBasicProfiling: [], rootSupportsTimelineProfiling: [], - suspenseTreeMutated: [], + suspenseTreeMutated: [[Map]], supportsNativeStyleEditor: [], supportsReloadAndProfile: [], unsupportedBridgeProtocolDetected: [], @@ -847,6 +848,83 @@ export default class Store extends EventEmitter<{ return list; } + getSuspenseLineage( + suspenseID: SuspenseNode['id'], + ): $ReadOnlyArray { + const lineage: Array = []; + let next: null | SuspenseNode = this.getSuspenseByID(suspenseID); + while (next !== null) { + if (next.parentID === 0) { + next = null; + } else { + lineage.unshift(next.id); + next = this.getSuspenseByID(next.parentID); + } + } + + return lineage; + } + + /** + * Like {@link getRootIDForElement} but should be used for traversing Suspense since it works with disconnected nodes. + */ + getSuspenseRootIDForSuspense(id: SuspenseNode['id']): number | null { + let current = this._idToSuspense.get(id); + while (current !== undefined) { + if (current.parentID === 0) { + return current.id; + } else { + current = this._idToSuspense.get(current.parentID); + } + } + return null; + } + + /** + * @param rootID + * @param uniqueSuspendersOnly Filters out boundaries without unique suspenders + */ + getSuspendableDocumentOrderSuspense( + rootID: Element['id'] | void, + uniqueSuspendersOnly: boolean, + ): $ReadOnlyArray { + if (rootID === undefined) { + return []; + } + const root = this.getElementByID(rootID); + if (root === null) { + return []; + } + if (!this.supportsTogglingSuspense(root.id)) { + return []; + } + const list: SuspenseNode['id'][] = []; + const suspense = this.getSuspenseByID(root.id); + if (suspense !== null) { + const stack = [suspense]; + while (stack.length > 0) { + const current = stack.pop(); + if (current === undefined) { + continue; + } + // Include the root even if we won't show it suspended (because that's just blank). + // You should be able to see what suspended the shell. + if (!uniqueSuspendersOnly || current.hasUniqueSuspenders) { + list.push(current.id); + } + // Add children in reverse order to maintain document order + for (let j = current.children.length - 1; j >= 0; j--) { + const childSuspense = this.getSuspenseByID(current.children[j]); + if (childSuspense !== null) { + stack.push(childSuspense); + } + } + } + } + + return list; + } + getRendererIDForElement(id: number): number | null { let current = this._idToElement.get(id); while (current !== undefined) { @@ -1030,6 +1108,8 @@ export default class Store extends EventEmitter<{ const addedElementIDs: Array = []; // This is a mapping of removed ID -> parent ID: const removedElementIDs: Map = new Map(); + const removedSuspenseIDs: Map = + new Map(); // We'll use the parent ID to adjust selection if it gets deleted. let i = 2; @@ -1508,6 +1588,7 @@ export default class Store extends EventEmitter<{ children: [], name, rects, + hasUniqueSuspenders: false, }); hasSuspenseTreeChanged = true; @@ -1541,6 +1622,7 @@ export default class Store extends EventEmitter<{ } this._idToSuspense.delete(id); + removedSuspenseIDs.set(id, parentID); let parentSuspense: ?SuspenseNode = null; if (parentID === 0) { @@ -1676,6 +1758,42 @@ export default class Store extends EventEmitter<{ break; } + case SUSPENSE_TREE_OPERATION_SUSPENDERS: { + const changeLength = operations[i + 1]; + i += 2; + + for (let changeIndex = 0; changeIndex < changeLength; changeIndex++) { + const id = operations[i]; + const hasUniqueSuspenders = operations[i + 1] === 1; + const suspense = this._idToSuspense.get(id); + + if (suspense === undefined) { + this._throwAndEmitError( + Error( + `Cannot update suspenders of suspense node "${id}" because no matching node was found in the Store.`, + ), + ); + + break; + } + + i += 2; + + if (__DEBUG__) { + const previousHasUniqueSuspenders = suspense.hasUniqueSuspenders; + debug( + 'Suspender changes', + `Suspense node ${id} unique suspenders set to ${String(hasUniqueSuspenders)} (was ${String(previousHasUniqueSuspenders)})`, + ); + } + + suspense.hasUniqueSuspenders = hasUniqueSuspenders; + } + + hasSuspenseTreeChanged = true; + + break; + } default: this._throwAndEmitError( new UnsupportedBridgeOperationError( @@ -1748,7 +1866,7 @@ export default class Store extends EventEmitter<{ } if (hasSuspenseTreeChanged) { - this.emit('suspenseTreeMutated'); + this.emit('suspenseTreeMutated', [removedSuspenseIDs]); } if (__DEBUG__) { diff --git a/packages/react-devtools-shared/src/devtools/views/ButtonIcon.js b/packages/react-devtools-shared/src/devtools/views/ButtonIcon.js index 454497e6b02da..a94766d4f1235 100644 --- a/packages/react-devtools-shared/src/devtools/views/ButtonIcon.js +++ b/packages/react-devtools-shared/src/devtools/views/ButtonIcon.js @@ -52,7 +52,7 @@ type Props = { type: IconType, }; -const materialIconsViewBox = '0 -960 960 960'; +const panelIcons = '0 -960 960 820'; export default function ButtonIcon({className = '', type}: Props): React.Node { let pathData = null; let viewBox = '0 0 24 24'; @@ -131,27 +131,27 @@ export default function ButtonIcon({className = '', type}: Props): React.Node { break; case 'panel-left-close': pathData = PATH_MATERIAL_PANEL_LEFT_CLOSE; - viewBox = materialIconsViewBox; + viewBox = panelIcons; break; case 'panel-left-open': pathData = PATH_MATERIAL_PANEL_LEFT_OPEN; - viewBox = materialIconsViewBox; + viewBox = panelIcons; break; case 'panel-right-close': pathData = PATH_MATERIAL_PANEL_RIGHT_CLOSE; - viewBox = materialIconsViewBox; + viewBox = panelIcons; break; case 'panel-right-open': pathData = PATH_MATERIAL_PANEL_RIGHT_OPEN; - viewBox = materialIconsViewBox; + viewBox = panelIcons; break; case 'panel-bottom-open': pathData = PATH_MATERIAL_PANEL_BOTTOM_OPEN; - viewBox = materialIconsViewBox; + viewBox = panelIcons; break; case 'panel-bottom-close': pathData = PATH_MATERIAL_PANEL_BOTTOM_CLOSE; - viewBox = materialIconsViewBox; + viewBox = panelIcons; break; case 'suspend': pathData = PATH_SUSPEND; diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js b/packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js index 4b4e721ced61c..5637967a6abb2 100644 --- a/packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js @@ -20,6 +20,7 @@ import { SUSPENSE_TREE_OPERATION_REMOVE, SUSPENSE_TREE_OPERATION_REORDER_CHILDREN, SUSPENSE_TREE_OPERATION_RESIZE, + SUSPENSE_TREE_OPERATION_SUSPENDERS, } from 'react-devtools-shared/src/constants'; import { parseElementDisplayNameFromBackend, @@ -452,6 +453,18 @@ function updateTree( break; } + case SUSPENSE_TREE_OPERATION_SUSPENDERS: { + const changesLength = ((operations[i + 1]: any): number); + + if (__DEBUG__) { + const changes = operations.slice(i + 2, i + 2 + changesLength * 2); + debug('Suspender changes', `[${changes.join(',')}]`); + } + + i += 2 + changesLength * 2; + break; + } + default: throw Error(`Unsupported Bridge operation "${operation}"`); } diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.css b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.css index 324a95c5a5837..1e1544b477cab 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.css +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.css @@ -19,6 +19,9 @@ background: var(--color-button-background); border: none; border-radius: 0.25rem; + color: var(--color-button); + font-family: var(--font-family-monospace); + font-size: var(--font-size-monospace-normal); padding: 0.25rem; white-space: nowrap; } diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.js index 704a2dd4415b4..b49d0b5eb9ad0 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.js @@ -12,68 +12,52 @@ import typeof {SyntheticMouseEvent} from 'react-dom-bindings/src/events/Syntheti import * as React from 'react'; import {useContext} from 'react'; -import { - TreeDispatcherContext, - TreeStateContext, -} from '../Components/TreeContext'; +import {TreeDispatcherContext} from '../Components/TreeContext'; +import {StoreContext} from '../context'; import {useHighlightHostInstance} from '../hooks'; import styles from './SuspenseBreadcrumbs.css'; -import {useSuspenseStore} from './SuspenseTreeContext'; +import { + SuspenseTreeStateContext, + SuspenseTreeDispatcherContext, +} from './SuspenseTreeContext'; export default function SuspenseBreadcrumbs(): React$Node { - const store = useSuspenseStore(); - const dispatch = useContext(TreeDispatcherContext); - const {inspectedElementID} = useContext(TreeStateContext); + const store = useContext(StoreContext); + const treeDispatch = useContext(TreeDispatcherContext); + const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext); + const {selectedSuspenseID, lineage} = useContext(SuspenseTreeStateContext); const {highlightHostInstance, clearHighlightHostInstance} = useHighlightHostInstance(); - // TODO: Use the nearest Suspense boundary - const inspectedSuspenseID = inspectedElementID; - if (inspectedSuspenseID === null) { - return null; - } - - const suspense = store.getSuspenseByID(inspectedSuspenseID); - if (suspense === null) { - return null; - } - - const lineage: SuspenseNode[] = []; - let next: null | SuspenseNode = suspense; - while (next !== null) { - if (next.parentID === 0) { - next = null; - } else { - lineage.unshift(next); - next = store.getSuspenseByID(next.parentID); - } - } - - function handleClick(node: SuspenseNode, event: SyntheticMouseEvent) { + function handleClick(id: SuspenseNode['id'], event: SyntheticMouseEvent) { event.preventDefault(); - dispatch({type: 'SELECT_ELEMENT_BY_ID', payload: node.id}); + treeDispatch({type: 'SELECT_ELEMENT_BY_ID', payload: id}); + suspenseTreeDispatch({type: 'SELECT_SUSPENSE_BY_ID', payload: id}); } return (
    - {lineage.map((node, index) => { - return ( -
  1. - -
  2. - ); - })} + {lineage !== null && + lineage.map((id, index) => { + const node = store.getSuspenseByID(id); + + return ( +
  3. + +
  4. + ); + })}
); } diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js index ab1b6276b7507..a03439c07d9df 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js @@ -19,9 +19,13 @@ import { TreeDispatcherContext, TreeStateContext, } from '../Components/TreeContext'; +import {StoreContext} from '../context'; import {useHighlightHostInstance} from '../hooks'; import styles from './SuspenseRects.css'; -import {useSuspenseStore} from './SuspenseTreeContext'; +import { + SuspenseTreeStateContext, + SuspenseTreeDispatcherContext, +} from './SuspenseTreeContext'; import typeof { SyntheticMouseEvent, SyntheticPointerEvent, @@ -44,8 +48,9 @@ function SuspenseRects({ }: { suspenseID: SuspenseNode['id'], }): React$Node { - const dispatch = useContext(TreeDispatcherContext); - const store = useSuspenseStore(); + const store = useContext(StoreContext); + const treeDispatch = useContext(TreeDispatcherContext); + const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext); const {inspectedElementID} = useContext(TreeStateContext); @@ -64,7 +69,11 @@ function SuspenseRects({ return; } event.preventDefault(); - dispatch({type: 'SELECT_ELEMENT_BY_ID', payload: suspenseID}); + treeDispatch({type: 'SELECT_ELEMENT_BY_ID', payload: suspenseID}); + suspenseTreeDispatch({ + type: 'SET_SUSPENSE_LINEAGE', + payload: suspenseID, + }); } function handlePointerOver(event: SyntheticPointerEvent) { @@ -157,7 +166,7 @@ function SuspenseRectsShell({ }: { rootID: SuspenseNode['id'], }): React$Node { - const store = useSuspenseStore(); + const store = useContext(StoreContext); const root = store.getSuspenseByID(rootID); if (root === null) { console.warn(` Could not find suspense node id ${rootID}`); @@ -174,9 +183,9 @@ function SuspenseRectsShell({ } function SuspenseRectsContainer(): React$Node { - const store = useSuspenseStore(); + const store = useContext(StoreContext); // TODO: This relies on a full re-render of all children when the Suspense tree changes. - const roots = store.roots; + const {roots} = useContext(SuspenseTreeStateContext); const boundingRect = getDocumentBoundingRect(store, roots); diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.css b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.css index 6404c326278e7..33441bcf34c00 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.css +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.css @@ -3,6 +3,7 @@ display: flex; flex-direction: row; padding: 0.25rem; + align-items: center; } .SuspenseTimelineInput { diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js index bfd90d0dd757c..dd58703cb97c8 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js @@ -7,70 +7,53 @@ * @flow */ -import type {Element, SuspenseNode} from '../../../frontend/types'; -import type Store from '../../store'; - import * as React from 'react'; -import {useContext, useLayoutEffect, useMemo, useRef, useState} from 'react'; -import {BridgeContext} from '../context'; +import {useContext, useLayoutEffect, useRef} from 'react'; +import {BridgeContext, StoreContext} from '../context'; import {TreeDispatcherContext} from '../Components/TreeContext'; +import Tooltip from '../Components/reach-ui/tooltip'; import {useHighlightHostInstance} from '../hooks'; -import {useSuspenseStore} from './SuspenseTreeContext'; +import { + SuspenseTreeDispatcherContext, + SuspenseTreeStateContext, +} from './SuspenseTreeContext'; import styles from './SuspenseTimeline.css'; import typeof { SyntheticEvent, SyntheticPointerEvent, } from 'react-dom-bindings/src/events/SyntheticEvent'; -function getSuspendableDocumentOrderSuspense( - store: Store, - rootID: Element['id'] | void, -): Array { - if (rootID === undefined) { - return []; - } - const root = store.getElementByID(rootID); - if (root === null) { - return []; - } - if (!store.supportsTogglingSuspense(root.id)) { - return []; - } - const suspenseTreeList: SuspenseNode[] = []; - const suspense = store.getSuspenseByID(root.id); - if (suspense !== null) { - const stack = [suspense]; - while (stack.length > 0) { - const current = stack.pop(); - if (current === undefined) { - continue; - } - // Include the root even if we won't suspend it. - // You should be able to see what suspended the shell. - suspenseTreeList.push(current); - // Add children in reverse order to maintain document order - for (let j = current.children.length - 1; j >= 0; j--) { - const childSuspense = store.getSuspenseByID(current.children[j]); - if (childSuspense !== null) { - stack.push(childSuspense); - } - } - } - } - - return suspenseTreeList; -} - -function SuspenseTimelineInput({rootID}: {rootID: Element['id'] | void}) { +function SuspenseTimelineInput() { const bridge = useContext(BridgeContext); - const store = useSuspenseStore(); - const dispatch = useContext(TreeDispatcherContext); + const store = useContext(StoreContext); + const treeDispatch = useContext(TreeDispatcherContext); + const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext); const {highlightHostInstance, clearHighlightHostInstance} = useHighlightHostInstance(); - const timeline = useMemo(() => { - return getSuspendableDocumentOrderSuspense(store, rootID); - }, [store, store.revisionSuspense, rootID]); + const { + selectedRootID: rootID, + timeline, + timelineIndex, + uniqueSuspendersOnly, + } = useContext(SuspenseTreeStateContext); + + function handleToggleUniqueSuspenders(event: SyntheticEvent) { + const nextUniqueSuspendersOnly = (event.currentTarget as HTMLInputElement) + .checked; + const nextTimeline = + rootID === null + ? [] + : // TODO: Handle different timeline modes (e.g. random order) + store.getSuspendableDocumentOrderSuspense( + rootID, + nextUniqueSuspendersOnly, + ); + suspenseTreeDispatch({ + type: 'SET_SUSPENSE_TIMELINE', + payload: [nextTimeline, null, nextUniqueSuspendersOnly], + }); + } const inputRef = useRef(null); const inputBBox = useRef(null); @@ -97,15 +80,11 @@ function SuspenseTimelineInput({rootID}: {rootID: Element['id'] | void}) { const min = 0; const max = timeline.length > 0 ? timeline.length - 1 : 0; - const [value, setValue] = useState(max); - - if (value > max) { - // TODO: Handle timeline changes - setValue(max); - } - if (rootID === undefined) { - return
Root not found.
; + if (rootID === null) { + return ( +
No root selected.
+ ); } if (!store.supportsTogglingSuspense(rootID)) { @@ -124,8 +103,21 @@ function SuspenseTimelineInput({rootID}: {rootID: Element['id'] | void}) { ); } + function switchSuspenseNode(nextTimelineIndex: number) { + const nextSelectedSuspenseID = timeline[nextTimelineIndex]; + highlightHostInstance(nextSelectedSuspenseID); + treeDispatch({ + type: 'SELECT_ELEMENT_BY_ID', + payload: nextSelectedSuspenseID, + }); + suspenseTreeDispatch({ + type: 'SUSPENSE_SET_TIMELINE_INDEX', + payload: nextTimelineIndex, + }); + } + function handleChange(event: SyntheticEvent) { - if (rootID === undefined) { + if (rootID === null) { return; } const rendererID = store.getRendererIDForElement(rootID); @@ -136,10 +128,8 @@ function SuspenseTimelineInput({rootID}: {rootID: Element['id'] | void}) { return; } - const pendingValue = +event.currentTarget.value; - const suspendedSet = timeline - .slice(pendingValue) - .map(suspense => suspense.id); + const pendingTimelineIndex = +event.currentTarget.value; + const suspendedSet = timeline.slice(pendingTimelineIndex); bridge.send('overrideSuspenseMilestone', { rendererID, @@ -147,11 +137,7 @@ function SuspenseTimelineInput({rootID}: {rootID: Element['id'] | void}) { suspendedSet, }); - const suspense = timeline[pendingValue]; - const elementID = suspense.id; - highlightHostInstance(elementID); - dispatch({type: 'SELECT_ELEMENT_BY_ID', payload: elementID}); - setValue(pendingValue); + switchSuspenseNode(pendingTimelineIndex); } function handleBlur() { @@ -159,10 +145,7 @@ function SuspenseTimelineInput({rootID}: {rootID: Element['id'] | void}) { } function handleFocus() { - const suspense = timeline[value]; - - dispatch({type: 'SELECT_ELEMENT_BY_ID', payload: suspense.id}); - highlightHostInstance(suspense.id); + switchSuspenseNode(timelineIndex); } function handlePointerMove(event: SyntheticPointerEvent) { @@ -180,27 +163,25 @@ function SuspenseTimelineInput({rootID}: {rootID: Element['id'] | void}) { max, ), ); - const suspense = timeline[hoveredValue]; - if (suspense === undefined) { + const suspenseID = timeline[hoveredValue]; + if (suspenseID === undefined) { throw new Error( `Suspense node not found for value ${hoveredValue} in timeline when on ${event.clientX} in bounding box ${JSON.stringify(bbox)}.`, ); } - highlightHostInstance(suspense.id); + highlightHostInstance(suspenseID); } return ( <> -
- {value}/{max} -
+ {timelineIndex}/{max}
+ + + ); } export default function SuspenseTimeline(): React$Node { - const store = useSuspenseStore(); - - const roots = store.roots; - const defaultSelectedRootID = roots.find(rootID => { - const suspense = store.getSuspenseByID(rootID); - return ( - store.supportsTogglingSuspense(rootID) && - suspense !== null && - suspense.children.length > 1 - ); - }); - const [selectedRootID, setSelectedRootID] = useState(defaultSelectedRootID); - - if (selectedRootID === undefined && defaultSelectedRootID !== undefined) { - setSelectedRootID(defaultSelectedRootID); - } + const store = useContext(StoreContext); + const {roots, selectedRootID, uniqueSuspendersOnly} = useContext( + SuspenseTreeStateContext, + ); + const treeDispatch = useContext(TreeDispatcherContext); + const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext); function handleChange(event: SyntheticEvent) { const newRootID = +event.currentTarget.value; // TODO: scrollIntoView both suspense rects and host instance. - setSelectedRootID(newRootID); + const nextTimeline = store.getSuspendableDocumentOrderSuspense( + newRootID, + uniqueSuspendersOnly, + ); + suspenseTreeDispatch({ + type: 'SET_SUSPENSE_TIMELINE', + payload: [nextTimeline, newRootID, uniqueSuspendersOnly], + }); + if (nextTimeline.length > 0) { + const milestone = nextTimeline[nextTimeline.length - 1]; + treeDispatch({type: 'SELECT_ELEMENT_BY_ID', payload: milestone}); + } } return (
- + {roots.length > 0 && (