diff --git a/packages/react-devtools-shared/src/__tests__/store-test.js b/packages/react-devtools-shared/src/__tests__/store-test.js index 330c8ab89c852..2d092ceb0ff06 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 1ae7f5dfb11b7..98091a06d6a8f 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 d2f5c801aab7a..7fd7000d440cb 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 9cdd63e150f7c..75763b1f18499 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 d1623ff24bfdd..2915d2cd30554 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 12b082aeb2e55..9d3e5a0d04e25 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 ccc66744a7046..616f2d3d3ec23 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 664b65bf7d7a8..02e60a080af3e 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 e0bd4e7c73c5e..4b4e721ced61c 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/SuspenseRects.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js index 65fa4dec07396..b1c30a32300ff 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js @@ -55,7 +55,7 @@ function SuspenseRects({ const suspense = store.getSuspenseByID(suspenseID); if (suspense === null) { - console.warn(` Could not find suspense node id ${suspenseID}`); + // getSuspenseByID will have already warned return null; } 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 3a7bb0735012b..dc82b25da2178 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/SuspenseTab.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js index 9df107feab354..a65a03efb4493 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js @@ -295,7 +295,7 @@ function SuspenseTab(_: {}) { ref={resizeTreeListRef}> -
+ -
+