diff --git a/shells/dev/app/index.js b/shells/dev/app/index.js index 1daae45595..e731e64470 100644 --- a/shells/dev/app/index.js +++ b/shells/dev/app/index.js @@ -32,6 +32,7 @@ function mountTestApp() { mountHelper(ElementTypes); mountHelper(EditableProps); mountHelper(DeeplyNestedComponents); + mountHelper(SuspenseTree); } function unmountTestApp() { diff --git a/src/backend/agent.js b/src/backend/agent.js index ddbcee3e2c..1f49a0fa2d 100644 --- a/src/backend/agent.js +++ b/src/backend/agent.js @@ -38,6 +38,12 @@ type SetInParams = {| value: any, |}; +type OverrideSuspenseParams = {| + id: number, + rendererID: number, + forceFallback: boolean, +|}; + export default class Agent extends EventEmitter { _bridge: Bridge = ((null: any): Bridge); _isProfiling: boolean = false; @@ -68,6 +74,7 @@ export default class Agent extends EventEmitter { bridge.addListener('overrideHookState', this.overrideHookState); bridge.addListener('overrideProps', this.overrideProps); bridge.addListener('overrideState', this.overrideState); + bridge.addListener('overrideSuspense', this.overrideSuspense); bridge.addListener('reloadAndProfile', this.reloadAndProfile); bridge.addListener('screenshotCaptured', this.screenshotCaptured); bridge.addListener('selectElement', this.selectElement); @@ -302,6 +309,19 @@ export default class Agent extends EventEmitter { } }; + overrideSuspense = ({ + id, + rendererID, + forceFallback, + }: OverrideSuspenseParams) => { + const renderer = this._rendererInterfaces[rendererID]; + if (renderer == null) { + console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`); + } else { + renderer.overrideSuspense(id, forceFallback); + } + }; + setRendererInterface( rendererID: RendererID, rendererInterface: RendererInterface diff --git a/src/backend/renderer.js b/src/backend/renderer.js index c6381cdb27..ea57106438 100644 --- a/src/backend/renderer.js +++ b/src/backend/renderer.js @@ -199,6 +199,7 @@ export function attach( IndeterminateComponent, MemoComponent, SimpleMemoComponent, + SuspenseComponent, } = ReactTypeOfWork; const { CONCURRENT_MODE_NUMBER, @@ -219,7 +220,15 @@ export function attach( DEPRECATED_PLACEHOLDER_SYMBOL_STRING, } = ReactSymbols; - const { overrideHookState, overrideProps } = renderer; + const { + overrideHookState, + overrideProps, + setSuspenseHandler, + scheduleUpdate, + } = renderer; + const supportsEditingSuspense = + typeof setSuspenseHandler === 'function' && + typeof scheduleUpdate === 'function'; const debug = (name: string, fiber: Fiber, parentFiber: ?Fiber): void => { if (__DEBUG__) { @@ -845,8 +854,7 @@ export function attach( // Suspense components only have a non-null memoizedState if they're timed-out. const isTimedOutSuspense = - nextFiber.tag === ReactTypeOfWork.SuspenseComponent && - nextFiber.memoizedState !== null; + nextFiber.tag === SuspenseComponent && nextFiber.memoizedState !== null; if (isTimedOutSuspense) { // The behavior of timed-out Suspense trees is unique. @@ -1411,6 +1419,9 @@ export function attach( } } + const isTimedOutSuspense = + tag === SuspenseComponent && memoizedState !== null; + return { id, @@ -1420,6 +1431,14 @@ export function attach( // Does the current renderer support editable function props? canEditFunctionProps: typeof overrideProps === 'function', + canEditSuspense: + supportsEditingSuspense && + // If it's showing the real content, we can always flip fallback. + (!isTimedOutSuspense || + // If it's showing fallback because we previously forced it to, + // allow toggling it back to remove the fallback override. + forceFallbackForSuspenseIDs.has(id)), + // Can view component source location. canViewSource, @@ -1667,6 +1686,45 @@ export function attach( startProfiling(); } + // React will switch between these implementations depending on whether + // we have any manually suspended Fibers or not. + + function shouldSuspendFiberAlwaysFalse() { + return false; + } + + let forceFallbackForSuspenseIDs = new Set(); + function shouldSuspendFiberAccordingToSet(fiber) { + const id = getFiberID(getPrimaryFiber(((fiber: any): Fiber))); + return forceFallbackForSuspenseIDs.has(id); + } + + function overrideSuspense(id, forceFallback) { + if ( + typeof setSuspenseHandler !== 'function' || + typeof scheduleUpdate !== 'function' + ) { + throw new Error( + 'Expected overrideSuspense() to not get called for earlier React versions.' + ); + } + if (forceFallback) { + forceFallbackForSuspenseIDs.add(id); + if (forceFallbackForSuspenseIDs.size === 1) { + // First override is added. Switch React to slower path. + setSuspenseHandler(shouldSuspendFiberAccordingToSet); + } + } else { + forceFallbackForSuspenseIDs.delete(id); + if (forceFallbackForSuspenseIDs.size === 0) { + // Last override is gone. Switch React back to fast path. + setSuspenseHandler(shouldSuspendFiberAlwaysFalse); + } + } + const fiber = idToFiberMap.get(id); + scheduleUpdate(fiber); + } + return { cleanup, flushInitialOperations, @@ -1680,6 +1738,7 @@ export function attach( handleCommitFiberUnmount, inspectElement, prepareViewElementSource, + overrideSuspense, renderer, selectElement, setInContext, diff --git a/src/backend/types.js b/src/backend/types.js index 83ad660ee7..b18c900d62 100644 --- a/src/backend/types.js +++ b/src/backend/types.js @@ -45,6 +45,10 @@ export type ReactRenderer = { value: any ) => void, + // 16.9+ + scheduleUpdate?: ?(fiber: Object) => void, + setSuspenseHandler?: ?(shouldSuspend: (fiber: Object) => boolean) => void, + // Only injected by React v16.8+ in order to support hooks inspection. currentDispatcherRef?: {| current: null | Dispatcher |}, }; @@ -95,6 +99,7 @@ export type RendererInterface = { handleCommitFiberRoot: (fiber: Object) => void, handleCommitFiberUnmount: (fiber: Object) => void, inspectElement: (id: number) => InspectedElement | null, + overrideSuspense: (id: number, forceFallback: boolean) => void, prepareViewElementSource: (id: number) => void, renderer: ReactRenderer | null, selectElement: (id: number) => void, diff --git a/src/devtools/views/Components/SelectedElement.js b/src/devtools/views/Components/SelectedElement.js index 1872ca991f..6f4958040b 100644 --- a/src/devtools/views/Components/SelectedElement.js +++ b/src/devtools/views/Components/SelectedElement.js @@ -20,6 +20,7 @@ import { ElementTypeForwardRef, ElementTypeFunction, ElementTypeMemo, + ElementTypeSuspense, } from '../../types'; import type { InspectedElement } from './types'; @@ -115,6 +116,8 @@ type InspectedElementViewProps = {| inspectedElement: InspectedElement, |}; +const IS_SUSPENDED = 'Suspended'; + function InspectedElementView({ element, inspectedElement, @@ -123,6 +126,7 @@ function InspectedElementView({ const { canEditFunctionProps, canEditHooks, + canEditSuspense, context, hooks, owners, @@ -137,6 +141,7 @@ function InspectedElementView({ let overrideContextFn = null; let overridePropsFn = null; let overrideStateFn = null; + let overrideSuspenseFn = null; if (type === ElementTypeClass) { overrideContextFn = (path: Array, value: any) => { const rendererID = store.getRendererIDForElement(id); @@ -160,6 +165,14 @@ function InspectedElementView({ const rendererID = store.getRendererIDForElement(id); bridge.send('overrideProps', { id, path, rendererID, value }); }; + } else if (type === ElementTypeSuspense && canEditSuspense) { + overrideSuspenseFn = (path: Array, value: boolean) => { + if (path.length !== 1 && path !== IS_SUSPENDED) { + throw new Error('Unexpected path.'); + } + const rendererID = store.getRendererIDForElement(id); + bridge.send('overrideSuspense', { id, rendererID, forceFallback: value }); + }; } return ( @@ -170,11 +183,21 @@ function InspectedElementView({ overrideValueFn={overridePropsFn} showWhenEmpty /> - + {type === ElementTypeSuspense ? ( + + ) : ( + + )}