diff --git a/packages/react-devtools-shared/src/__tests__/store-test.js b/packages/react-devtools-shared/src/__tests__/store-test.js index 330c8ab89c..2d092ceb0f 100644 --- a/packages/react-devtools-shared/src/__tests__/store-test.js +++ b/packages/react-devtools-shared/src/__tests__/store-test.js @@ -901,6 +901,94 @@ describe('Store', () => { `); }); + // @reactVersion >= 18.0 + it('can override multiple Suspense simultaneously', async () => { + const Component = () => { + return
Hello
; + }; + const App = () => ( + + + }> + + }> + + + }> + + + }> + + + + + + ); + + await actAsync(() => render()); + + expect(store).toMatchInlineSnapshot(` + [root] + ▾ + + ▾ + + ▾ + + ▾ + + ▾ + + + [shell] + + + + + `); + + const rendererID = getRendererID(); + const rootID = store.getRootIDForElement(store.getElementIDAtIndex(0)); + await actAsync(() => { + agent.overrideSuspenseMilestone({ + rendererID, + rootID, + suspendedSet: [ + store.getElementIDAtIndex(4), + store.getElementIDAtIndex(8), + ], + }); + }); + + expect(store).toMatchInlineSnapshot(` + [root] + ▾ + + ▾ + + ▾ + + ▾ + + ▾ + + + [shell] + + + + + `); + }); + it('should display a partially rendered SuspenseList', async () => { const Loading = () =>
Loading...
; const SuspendingComponent = () => { diff --git a/packages/react-devtools-shared/src/backend/agent.js b/packages/react-devtools-shared/src/backend/agent.js index 1ae7f5dfb1..98091a06d6 100644 --- a/packages/react-devtools-shared/src/backend/agent.js +++ b/packages/react-devtools-shared/src/backend/agent.js @@ -130,6 +130,12 @@ type OverrideSuspenseParams = { forceFallback: boolean, }; +type OverrideSuspenseMilestoneParams = { + rendererID: number, + rootID: number, + suspendedSet: Array, +}; + type PersistedSelection = { rendererID: number, path: Array, @@ -198,6 +204,10 @@ export default class Agent extends EventEmitter<{ bridge.addListener('logElementToConsole', this.logElementToConsole); bridge.addListener('overrideError', this.overrideError); bridge.addListener('overrideSuspense', this.overrideSuspense); + bridge.addListener( + 'overrideSuspenseMilestone', + this.overrideSuspenseMilestone, + ); bridge.addListener('overrideValueAtPath', this.overrideValueAtPath); bridge.addListener('reloadAndProfile', this.reloadAndProfile); bridge.addListener('renamePath', this.renamePath); @@ -556,6 +566,21 @@ export default class Agent extends EventEmitter<{ } }; + overrideSuspenseMilestone: OverrideSuspenseMilestoneParams => void = ({ + rendererID, + rootID, + suspendedSet, + }) => { + const renderer = this._rendererInterfaces[rendererID]; + if (renderer == null) { + console.warn( + `Invalid renderer id "${rendererID}" to override suspense milestone`, + ); + } else { + renderer.overrideSuspenseMilestone(rootID, suspendedSet); + } + }; + overrideValueAtPath: OverrideValueAtPathParams => void = ({ hookID, id, diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index d2f5c801aa..7fd7000d44 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -2366,6 +2366,7 @@ export function attach( !isProductionBuildOfRenderer && StrictModeBits !== 0 ? 1 : 0, ); pushOperation(hasOwnerMetadata ? 1 : 0); + pushOperation(supportsTogglingSuspense ? 1 : 0); if (isProfiling) { if (displayNamesByRootID !== null) { @@ -7455,13 +7456,6 @@ export function attach( } function overrideSuspense(id: number, forceFallback: boolean) { - if (!supportsTogglingSuspense) { - // TODO:: Add getter to decide if overrideSuspense is available. - // Currently only available on inspectElement. - // Probably need a different affordance to batch since the timeline - // fallback is not the same as resuspending. - return; - } if ( typeof setSuspenseHandler !== 'function' || typeof scheduleUpdate !== 'function' @@ -7506,6 +7500,58 @@ export function attach( scheduleUpdate(fiber); } + /** + * Resets the all other roots of this renderer. + * @param rootID The root that contains this milestone + * @param suspendedSet List of IDs of SuspenseComponent Fibers + */ + function overrideSuspenseMilestone( + rootID: FiberInstance['id'], + suspendedSet: Array, + ) { + if ( + typeof setSuspenseHandler !== 'function' || + typeof scheduleUpdate !== 'function' + ) { + throw new Error( + 'Expected overrideSuspenseMilestone() to not get called for earlier React versions.', + ); + } + + // TODO: Allow overriding the timeline for the specified root. + forceFallbackForFibers.clear(); + + for (let i = 0; i < suspendedSet.length; ++i) { + const instance = idToDevToolsInstanceMap.get(suspendedSet[i]); + if (instance === undefined) { + console.warn( + `Could not suspend ID '${suspendedSet[i]}' since the instance can't be found.`, + ); + continue; + } + + if (instance.kind === FIBER_INSTANCE) { + const fiber = instance.data; + forceFallbackForFibers.add(fiber); + // We could find a minimal set that covers all the Fibers in this suspended set. + // For now we rely on React's batching of updates. + scheduleUpdate(fiber); + } else { + console.warn(`Cannot not suspend ID '${suspendedSet[i]}'.`); + } + } + + if (forceFallbackForFibers.size > 0) { + // First override is added. Switch React to slower path. + // TODO: Semantics for suspending a timeline are different. We want a suspended + // timeline to act like a first reveal which is relevant for SuspenseList. + // Resuspending would not affect rows in SuspenseList + setSuspenseHandler(shouldSuspendFiberAccordingToSet); + } else { + setSuspenseHandler(shouldSuspendFiberAlwaysFalse); + } + } + // Remember if we're trying to restore the selection after reload. // In that case, we'll do some extra checks for matching mounts. let trackedPath: Array | null = null; @@ -8006,6 +8052,7 @@ export function attach( onErrorOrWarning, overrideError, overrideSuspense, + overrideSuspenseMilestone, overrideValueAtPath, renamePath, renderer, @@ -8014,6 +8061,7 @@ export function attach( startProfiling, stopProfiling, storeAsGlobal, + supportsTogglingSuspense, updateComponentFilters, getEnvironmentNames, ...internalMcpFunctions, diff --git a/packages/react-devtools-shared/src/backend/flight/renderer.js b/packages/react-devtools-shared/src/backend/flight/renderer.js index 9cdd63e150..75763b1f18 100644 --- a/packages/react-devtools-shared/src/backend/flight/renderer.js +++ b/packages/react-devtools-shared/src/backend/flight/renderer.js @@ -140,6 +140,8 @@ export function attach( // The changes will be flushed later when we commit this tree to Fiber. } + const supportsTogglingSuspense = false; + return { cleanup() {}, clearErrorsAndWarnings() {}, @@ -202,6 +204,7 @@ export function attach( onErrorOrWarning, overrideError() {}, overrideSuspense() {}, + overrideSuspenseMilestone() {}, overrideValueAtPath() {}, renamePath() {}, renderer, @@ -210,6 +213,7 @@ export function attach( startProfiling() {}, stopProfiling() {}, storeAsGlobal() {}, + supportsTogglingSuspense, updateComponentFilters() {}, getEnvironmentNames() { return []; diff --git a/packages/react-devtools-shared/src/backend/legacy/renderer.js b/packages/react-devtools-shared/src/backend/legacy/renderer.js index d1623ff24b..2915d2cd30 100644 --- a/packages/react-devtools-shared/src/backend/legacy/renderer.js +++ b/packages/react-devtools-shared/src/backend/legacy/renderer.js @@ -180,6 +180,8 @@ export function attach( }; } + const supportsTogglingSuspense = false; + function getDisplayNameForElementID(id: number): string | null { const internalInstance = idToInternalInstanceMap.get(id); return internalInstance ? getData(internalInstance).displayName : null; @@ -408,6 +410,7 @@ export function attach( pushOperation(0); // Profiling flag pushOperation(0); // StrictMode supported? pushOperation(hasOwnerMetadata ? 1 : 0); + pushOperation(supportsTogglingSuspense ? 1 : 0); } else { const type = getElementType(internalInstance); const {displayName, key} = getData(internalInstance); @@ -1070,6 +1073,9 @@ export function attach( const overrideSuspense = () => { throw new Error('overrideSuspense not supported by this renderer'); }; + const overrideSuspenseMilestone = () => { + throw new Error('overrideSuspenseMilestone not supported by this renderer'); + }; const startProfiling = () => { // Do not throw, since this would break a multi-root scenario where v15 and v16 were both present. }; @@ -1153,6 +1159,7 @@ export function attach( logElementToConsole, overrideError, overrideSuspense, + overrideSuspenseMilestone, overrideValueAtPath, renamePath, getElementAttributeByPath, @@ -1163,6 +1170,7 @@ export function attach( startProfiling, stopProfiling, storeAsGlobal, + supportsTogglingSuspense, updateComponentFilters, getEnvironmentNames, }; diff --git a/packages/react-devtools-shared/src/backend/types.js b/packages/react-devtools-shared/src/backend/types.js index 12b082aeb2..9d3e5a0d04 100644 --- a/packages/react-devtools-shared/src/backend/types.js +++ b/packages/react-devtools-shared/src/backend/types.js @@ -437,6 +437,10 @@ export type RendererInterface = { onErrorOrWarning?: OnErrorOrWarning, overrideError: (id: number, forceError: boolean) => void, overrideSuspense: (id: number, forceFallback: boolean) => void, + overrideSuspenseMilestone: ( + rootID: number, + suspendedSet: Array, + ) => void, overrideValueAtPath: ( type: Type, id: number, @@ -469,6 +473,7 @@ export type RendererInterface = { path: Array, count: number, ) => void, + supportsTogglingSuspense: boolean, updateComponentFilters: (componentFilters: Array) => void, getEnvironmentNames: () => Array, diff --git a/packages/react-devtools-shared/src/bridge.js b/packages/react-devtools-shared/src/bridge.js index ccc66744a7..616f2d3d3e 100644 --- a/packages/react-devtools-shared/src/bridge.js +++ b/packages/react-devtools-shared/src/bridge.js @@ -27,7 +27,7 @@ export type BridgeProtocol = { // Version supported by the current frontend/backend. version: number, - // NPM version range that also supports this version. + // NPM version range of `react-devtools-inline` that also supports this version. // Note that 'maxNpmVersion' is only set when the version is bumped. minNpmVersion: string, maxNpmVersion: string | null, @@ -65,6 +65,12 @@ export const BRIDGE_PROTOCOL: Array = [ { version: 2, minNpmVersion: '4.22.0', + maxNpmVersion: '6.2.0', + }, + // Version 3 adds supports-toggling-suspense bit to add-root + { + version: 3, + minNpmVersion: '6.2.0', maxNpmVersion: null, }, ]; @@ -134,6 +140,12 @@ type OverrideSuspense = { forceFallback: boolean, }; +type OverrideSuspenseMilestone = { + rendererID: number, + rootID: number, + suspendedSet: Array, +}; + type CopyElementPathParams = { ...ElementAndRendererID, path: Array, @@ -231,6 +243,7 @@ type FrontendEvents = { logElementToConsole: [ElementAndRendererID], overrideError: [OverrideError], overrideSuspense: [OverrideSuspense], + overrideSuspenseMilestone: [OverrideSuspenseMilestone], overrideValueAtPath: [OverrideValueAtPath], profilingData: [ProfilingDataBackend], reloadAndProfile: [ReloadAndProfilingParams], diff --git a/packages/react-devtools-shared/src/devtools/store.js b/packages/react-devtools-shared/src/devtools/store.js index 664b65bf7d..02e60a080a 100644 --- a/packages/react-devtools-shared/src/devtools/store.js +++ b/packages/react-devtools-shared/src/devtools/store.js @@ -89,6 +89,7 @@ export type Capabilities = { supportsBasicProfiling: boolean, hasOwnerMetadata: boolean, supportsStrictMode: boolean, + supportsTogglingSuspense: boolean, supportsTimeline: boolean, }; @@ -491,6 +492,14 @@ export default class Store extends EventEmitter<{ ); } + supportsTogglingSuspense(rootID: Element['id']): boolean { + const capabilities = this._rootIDToCapabilities.get(rootID); + if (capabilities === undefined) { + throw new Error(`No capabilities registered for root ${rootID}`); + } + return capabilities.supportsTogglingSuspense; + } + // This build of DevTools supports the Timeline profiler. // This is a static flag, controlled by the Store config. get supportsTimeline(): boolean { @@ -1080,6 +1089,7 @@ export default class Store extends EventEmitter<{ let supportsStrictMode = false; let hasOwnerMetadata = false; + let supportsTogglingSuspense = false; // If we don't know the bridge protocol, guess that we're dealing with the latest. // If we do know it, we can take it into consideration when parsing operations. @@ -1092,6 +1102,9 @@ export default class Store extends EventEmitter<{ hasOwnerMetadata = operations[i] > 0; i++; + + supportsTogglingSuspense = operations[i] > 0; + i++; } this._roots = this._roots.concat(id); @@ -1100,6 +1113,7 @@ export default class Store extends EventEmitter<{ supportsBasicProfiling, hasOwnerMetadata, supportsStrictMode, + supportsTogglingSuspense, supportsTimeline, }); 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 e0bd4e7c73..4b4e721ced 100644 --- a/packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js @@ -208,6 +208,7 @@ function updateTree( i++; // Profiling flag i++; // supportsStrictMode flag i++; // hasOwnerMetadata flag + i++; // supportsTogglingSuspense flag if (__DEBUG__) { debug('Add', `new root fiber ${id}`); diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.css b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.css index 3a7bb07350..dc82b25da2 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.css +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.css @@ -115,4 +115,5 @@ .Timeline { flex-grow: 1; + align-self: anchor-center; } 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 f07f4efb2c..5c053d9c73 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.css +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.css @@ -1,5 +1,18 @@ -.SuspenseTimelineSlider { +.SuspenseTimelineContainer { width: 100%; + display: flex; + flex-direction: row; +} + +.SuspenseTimelineInput { + display: flex; + flex-direction: column; + flex-grow: 1; +} + +.SuspenseTimelineRootSwitcher { + height: fit-content; + max-width: 3rem; } .SuspenseTimelineMarkers { @@ -18,3 +31,4 @@ .SuspenseTimelineActiveMarker { visibility: visible; } + 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 413760d078..78a2bd1135 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js @@ -29,34 +29,38 @@ import typeof { SyntheticPointerEvent, } from 'react-dom-bindings/src/events/SyntheticEvent'; -// TODO: This returns the roots which would mean we attempt to suspend the shell. -// Suspending the shell is currently not supported and we don't have a good view -// for inspecting the root. But we probably should? -function getDocumentOrderSuspense( +function getSuspendableDocumentOrderSuspense( store: Store, - roots: $ReadOnlyArray, + 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[] = []; - for (let i = 0; i < roots.length; i++) { - const root = store.getElementByID(roots[i]); - if (root === null) { - continue; - } - const suspense = store.getSuspenseByID(root.id); - if (suspense !== null) { - const stack = [suspense]; - while (stack.length > 0) { - const current = stack.pop(); - if (current === undefined) { - continue; - } + const suspense = store.getSuspenseByID(root.id); + if (suspense !== null) { + const stack = [suspense]; + while (stack.length > 0) { + const current = stack.pop(); + if (current === undefined) { + continue; + } + // Don't include the root. It's currently not supported to suspend the shell. + if (current !== suspense) { 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); - } + } + // 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); } } } @@ -65,22 +69,24 @@ function getDocumentOrderSuspense( return suspenseTreeList; } -export default function SuspenseTimeline(): React$Node { +function SuspenseTimelineInput({rootID}: {rootID: Element['id'] | void}) { const bridge = useContext(BridgeContext); const store = useContext(StoreContext); const dispatch = useContext(TreeDispatcherContext); - const {shells} = useContext(SuspenseTreeStateContext); - - const timeline = useMemo(() => { - return getDocumentOrderSuspense(store, shells); - }, [store, shells]); - const {highlightHostInstance, clearHighlightHostInstance} = useHighlightHostInstance(); + const timeline = useMemo(() => { + return getSuspendableDocumentOrderSuspense(store, rootID); + }, [store, rootID]); + const inputRef = useRef(null); const inputBBox = useRef(null); useLayoutEffect(() => { + if (timeline.length === 0) { + return; + } + const input = inputRef.current; if (input === null) { throw new Error('Expected an input HTML element to be present.'); @@ -95,12 +101,12 @@ export default function SuspenseTimeline(): React$Node { inputBBox.current = null; observer.disconnect(); }; - }, []); + }, [timeline.length]); 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); @@ -130,6 +136,26 @@ export default function SuspenseTimeline(): React$Node { }); }, [timeline, value]); + if (rootID === undefined) { + return
Root not found.
; + } + + if (!store.supportsTogglingSuspense(rootID)) { + return ( +
+ Can't step through Suspense in production apps. +
+ ); + } + + if (timeline.length === 0) { + return ( +
+ Root contains no Suspense nodes. +
+ ); + } + function handleChange(event: SyntheticEvent) { const pendingValue = +event.currentTarget.value; for (let i = 0; i < timeline.length; i++) { @@ -193,7 +219,7 @@ export default function SuspenseTimeline(): React$Node { } return ( -
+
); } + +export default function SuspenseTimeline(): React$Node { + const store = useContext(StoreContext); + const {shells} = useContext(SuspenseTreeStateContext); + + const defaultSelectedRootID = shells.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); + } + + function handleChange(event: SyntheticEvent) { + const newRootID = +event.currentTarget.value; + // TODO: scrollIntoView both suspense rects and host instance. + setSelectedRootID(newRootID); + } + + return ( +
+ + {shells.length > 0 && ( + + )} +
+ ); +} diff --git a/packages/react-devtools-shared/src/utils.js b/packages/react-devtools-shared/src/utils.js index 34c258ebe2..b404608e55 100644 --- a/packages/react-devtools-shared/src/utils.js +++ b/packages/react-devtools-shared/src/utils.js @@ -261,6 +261,7 @@ export function printOperationsArray(operations: Array) { i++; // supportsProfiling i++; // supportsStrictMode i++; // hasOwnerMetadata + i++; // supportsTogglingSuspense } else { const parentID = ((operations[i]: any): number); i++;