From 93a3935d0292d66e0bf426a7e28bb1433bbeea30 Mon Sep 17 00:00:00 2001 From: "Sebastian \"Sebbie\" Silbermann" Date: Tue, 3 Mar 2026 12:21:46 +0100 Subject: [PATCH 1/2] [DevTools] Only schedule a single update per Supense when changing timeline (#35927) --- .../src/__tests__/store-test.js | 2 + .../src/backend/agent.js | 14 +++--- packages/react-devtools-shared/src/bridge.js | 1 + .../src/devtools/store.js | 48 +++++++++++++++---- .../views/SuspenseTab/SuspenseTimeline.js | 40 ++++++++++++++-- .../src/frontend/types.js | 1 + 6 files changed, 86 insertions(+), 20 deletions(-) diff --git a/packages/react-devtools-shared/src/__tests__/store-test.js b/packages/react-devtools-shared/src/__tests__/store-test.js index 36198ac1e079..2e56462aac9a 100644 --- a/packages/react-devtools-shared/src/__tests__/store-test.js +++ b/packages/react-devtools-shared/src/__tests__/store-test.js @@ -981,6 +981,7 @@ describe('Store', () => { await actAsync(() => { agent.overrideSuspenseMilestone({ + rendererID: getRendererID(), suspendedSet: [ store.getElementIDAtIndex(4), store.getElementIDAtIndex(8), @@ -1010,6 +1011,7 @@ describe('Store', () => { await actAsync(() => { agent.overrideSuspenseMilestone({ + rendererID: getRendererID(), suspendedSet: [], }); }); diff --git a/packages/react-devtools-shared/src/backend/agent.js b/packages/react-devtools-shared/src/backend/agent.js index 78387326f379..18f3e208408b 100644 --- a/packages/react-devtools-shared/src/backend/agent.js +++ b/packages/react-devtools-shared/src/backend/agent.js @@ -147,6 +147,7 @@ type OverrideSuspenseParams = { }; type OverrideSuspenseMilestoneParams = { + rendererID: number, suspendedSet: Array, }; @@ -787,15 +788,14 @@ export default class Agent extends EventEmitter<{ }; overrideSuspenseMilestone: OverrideSuspenseMilestoneParams => void = ({ + rendererID, suspendedSet, }) => { - for (const rendererID in this._rendererInterfaces) { - const renderer = ((this._rendererInterfaces[ - (rendererID: any) - ]: any): RendererInterface); - if (renderer.supportsTogglingSuspense) { - renderer.overrideSuspenseMilestone(suspendedSet); - } + const renderer = ((this._rendererInterfaces[ + (rendererID: any) + ]: any): RendererInterface); + if (renderer.supportsTogglingSuspense) { + renderer.overrideSuspenseMilestone(suspendedSet); } }; diff --git a/packages/react-devtools-shared/src/bridge.js b/packages/react-devtools-shared/src/bridge.js index 2e30e909841f..ba4b2a0f8061 100644 --- a/packages/react-devtools-shared/src/bridge.js +++ b/packages/react-devtools-shared/src/bridge.js @@ -145,6 +145,7 @@ type OverrideSuspense = { }; type OverrideSuspenseMilestone = { + rendererID: number, suspendedSet: Array, }; diff --git a/packages/react-devtools-shared/src/devtools/store.js b/packages/react-devtools-shared/src/devtools/store.js index 5466e798aad4..3849da9e5736 100644 --- a/packages/react-devtools-shared/src/devtools/store.js +++ b/packages/react-devtools-shared/src/devtools/store.js @@ -957,6 +957,12 @@ export default class Store extends EventEmitter<{ if (root === null) { continue; } + const rendererID = this._rootIDToRendererID.get(rootID); + if (rendererID === undefined) { + throw new Error( + 'Failed to find renderer ID for root. This is a bug in React DevTools.', + ); + } // TODO: This includes boundaries that can't be suspended due to no support from the renderer. const suspense = this.getSuspenseByID(rootID); @@ -972,6 +978,7 @@ export default class Store extends EventEmitter<{ id: suspense.id, environment: environmentName, endTime: suspense.endTime, + rendererID, }; target.push(rootStep); } else { @@ -990,6 +997,7 @@ export default class Store extends EventEmitter<{ uniqueSuspendersOnly, environments, 0, // Don't pass a minimum end time at the root. The root is always first so doesn't matter. + rendererID, ); } } @@ -1039,6 +1047,7 @@ export default class Store extends EventEmitter<{ */ getSuspendableDocumentOrderSuspenseTransition( uniqueSuspendersOnly: boolean, + rendererID: number, ): Array { const target: Array = []; const focusedTransitionID = this._focusedTransition; @@ -1051,6 +1060,7 @@ export default class Store extends EventEmitter<{ // TODO: Get environment for Activity environment: null, endTime: 0, + rendererID, }); const transitionChildren = this.getSuspenseChildren(focusedTransitionID); @@ -1062,6 +1072,7 @@ export default class Store extends EventEmitter<{ // TODO: Get environment for Activity [], 0, // Don't pass a minimum end time at the root. The root is always first so doesn't matter. + rendererID, ); return target; @@ -1073,6 +1084,7 @@ export default class Store extends EventEmitter<{ uniqueSuspendersOnly: boolean, parentEnvironments: Array, parentEndTime: number, + rendererID: number, ): void { for (let i = 0; i < children.length; i++) { const child = this.getSuspenseByID(children[i]); @@ -1106,6 +1118,7 @@ export default class Store extends EventEmitter<{ id: child.id, environment: environmentName, endTime: maxEndTime, + rendererID, }); } this.pushTimelineStepsInDocumentOrder( @@ -1114,6 +1127,7 @@ export default class Store extends EventEmitter<{ uniqueSuspendersOnly, unionEnvironments, maxEndTime, + rendererID, ); } } @@ -1121,14 +1135,32 @@ export default class Store extends EventEmitter<{ getEndTimeOrDocumentOrderSuspense( uniqueSuspendersOnly: boolean, ): $ReadOnlyArray { - const timeline = - this._focusedTransition === 0 - ? this.getSuspendableDocumentOrderSuspenseInitialPaint( - uniqueSuspendersOnly, - ) - : this.getSuspendableDocumentOrderSuspenseTransition( - uniqueSuspendersOnly, - ); + let timeline: SuspenseTimelineStep[]; + if (this._focusedTransition === 0) { + timeline = + this.getSuspendableDocumentOrderSuspenseInitialPaint( + uniqueSuspendersOnly, + ); + } else { + const focusedTransitionRootID = this.getRootIDForElement( + this._focusedTransition, + ); + if (focusedTransitionRootID === null) { + throw new Error( + 'Failed to find root ID for focused transition. This is a bug in React DevTools.', + ); + } + const rendererID = this._rootIDToRendererID.get(focusedTransitionRootID); + if (rendererID === undefined) { + throw new Error( + 'Failed to find renderer ID for focused transition root. This is a bug in React DevTools.', + ); + } + timeline = this.getSuspendableDocumentOrderSuspenseTransition( + uniqueSuspendersOnly, + rendererID, + ); + } if (timeline.length === 0) { return timeline; 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 89f349ae6ea7..c7d9246fb457 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js @@ -9,7 +9,7 @@ import * as React from 'react'; import {useContext, useEffect} from 'react'; -import {BridgeContext} from '../context'; +import {BridgeContext, StoreContext} from '../context'; import {TreeDispatcherContext} from '../Components/TreeContext'; import {useScrollToHostInstance} from '../hooks'; import { @@ -20,9 +20,11 @@ import styles from './SuspenseTimeline.css'; import SuspenseScrubber from './SuspenseScrubber'; import Button from '../Button'; import ButtonIcon from '../ButtonIcon'; +import type {SuspenseNode} from '../../../frontend/types'; function SuspenseTimelineInput() { const bridge = useContext(BridgeContext); + const store = useContext(StoreContext); const treeDispatch = useContext(TreeDispatcherContext); const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext); const scrollToHostInstance = useScrollToHostInstance(); @@ -101,15 +103,43 @@ function SuspenseTimelineInput() { // TODO: useEffectEvent here once it's supported in all versions DevTools supports. // For now we just exclude it from deps since we don't lint those anyway. function changeTimelineIndex(newIndex: number) { + const suspendedSetByRendererID = new Map< + number, + Array, + >(); + // Unsuspend everything by default. + // We might not encounter every renderer after the milestone e.g. + // if we clicked at the end of the timeline. + // eslint-disable-next-line no-for-of-loops/no-for-of-loops + for (const rendererID of store.rootIDToRendererID.values()) { + suspendedSetByRendererID.set(rendererID, []); + } + // Synchronize timeline index with what is resuspended. // We suspend everything after the current selection. The root isn't showing // anything suspended in the root. The step after that should have one less // thing suspended. I.e. the first suspense boundary should be unsuspended // when it's selected. This also lets you show everything in the last step. - const suspendedSet = timeline.slice(timelineIndex + 1).map(step => step.id); - bridge.send('overrideSuspenseMilestone', { - suspendedSet, - }); + for (let i = timelineIndex + 1; i < timeline.length; i++) { + const step = timeline[i]; + const {rendererID} = step; + const suspendedSetForRendererID = + suspendedSetByRendererID.get(rendererID); + if (suspendedSetForRendererID === undefined) { + throw new Error( + `Should have initialized suspended set for renderer ID "${rendererID}" earlier. This is a bug in React DevTools.`, + ); + } + suspendedSetForRendererID.push(step.id); + } + + // eslint-disable-next-line no-for-of-loops/no-for-of-loops + for (const [rendererID, suspendedSet] of suspendedSetByRendererID) { + bridge.send('overrideSuspenseMilestone', { + rendererID, + suspendedSet, + }); + } } useEffect(() => { diff --git a/packages/react-devtools-shared/src/frontend/types.js b/packages/react-devtools-shared/src/frontend/types.js index a78831cf229b..98acc3ed43cc 100644 --- a/packages/react-devtools-shared/src/frontend/types.js +++ b/packages/react-devtools-shared/src/frontend/types.js @@ -210,6 +210,7 @@ export type SuspenseTimelineStep = { */ id: SuspenseNode['id'] | Element['id'], // TODO: Will become a group. environment: null | string, + rendererID: number, endTime: number, }; From aac12ce597b49093a5add54b00deee3d8980f874 Mon Sep 17 00:00:00 2001 From: Ruslan Lesiutin <28902667+hoxyq@users.noreply.github.com> Date: Tue, 3 Mar 2026 12:27:05 +0000 Subject: [PATCH 2/2] [DevTools] chore: extract pure functions from fiber/renderer.js (#35924) I am in a process of splitting down the renderer implementation into smaller units of logic that can be reused. This change is about extracting pure functions only. --- .../src/backend/DevToolsNativeHost.js | 64 +++ .../src/backend/fiber/renderer.js | 448 ++---------------- .../shared/DevToolsFiberChangeDetection.js | 150 ++++++ .../fiber/shared/DevToolsFiberInspection.js | 104 ++++ .../fiber/shared/DevToolsFiberSuspense.js | 47 ++ .../fiber/shared/DevToolsFiberTypes.js | 99 ++++ .../src/backend/types.js | 2 +- 7 files changed, 511 insertions(+), 403 deletions(-) create mode 100644 packages/react-devtools-shared/src/backend/DevToolsNativeHost.js create mode 100644 packages/react-devtools-shared/src/backend/fiber/shared/DevToolsFiberChangeDetection.js create mode 100644 packages/react-devtools-shared/src/backend/fiber/shared/DevToolsFiberInspection.js create mode 100644 packages/react-devtools-shared/src/backend/fiber/shared/DevToolsFiberSuspense.js create mode 100644 packages/react-devtools-shared/src/backend/fiber/shared/DevToolsFiberTypes.js diff --git a/packages/react-devtools-shared/src/backend/DevToolsNativeHost.js b/packages/react-devtools-shared/src/backend/DevToolsNativeHost.js new file mode 100644 index 000000000000..bb72210e3b48 --- /dev/null +++ b/packages/react-devtools-shared/src/backend/DevToolsNativeHost.js @@ -0,0 +1,64 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {HostInstance} from './types'; + +// Some environments (e.g. React Native / Hermes) don't support the performance API yet. +export const getCurrentTime: () => number = + // $FlowFixMe[method-unbinding] + typeof performance === 'object' && typeof performance.now === 'function' + ? () => performance.now() + : () => Date.now(); + +// Ideally, this should be injected from Reconciler config +export function getPublicInstance(instance: HostInstance): HostInstance { + // Typically the PublicInstance and HostInstance is the same thing but not in Fabric. + // So we need to detect this and use that as the public instance. + + // React Native. Modern. Fabric. + if (typeof instance === 'object' && instance !== null) { + if (typeof instance.canonical === 'object' && instance.canonical !== null) { + if ( + typeof instance.canonical.publicInstance === 'object' && + instance.canonical.publicInstance !== null + ) { + return instance.canonical.publicInstance; + } + } + + // React Native. Legacy. Paper. + if (typeof instance._nativeTag === 'number') { + return instance._nativeTag; + } + } + + // React Web. Usually a DOM element. + return instance; +} + +export function getNativeTag(instance: HostInstance): number | null { + if (typeof instance !== 'object' || instance === null) { + return null; + } + + // Modern. Fabric. + if ( + instance.canonical != null && + typeof instance.canonical.nativeTag === 'number' + ) { + return instance.canonical.nativeTag; + } + + // Legacy. Paper. + if (typeof instance._nativeTag === 'number') { + return instance._nativeTag; + } + + return null; +} diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 266346a6bbe7..49e6192b467e 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -18,7 +18,7 @@ import type { Wakeable, } from 'shared/ReactTypes'; -import type {HooksNode, HooksTree} from 'react-debug-tools/src/ReactDebugHooks'; +import type {HooksTree} from 'react-debug-tools/src/ReactDebugHooks'; import { ComponentFilterDisplayName, @@ -124,10 +124,32 @@ import {enableStyleXFeatures} from 'react-devtools-feature-flags'; import {componentInfoToComponentLogsMap} from '../shared/DevToolsServerComponentLogs'; -import is from 'shared/objectIs'; - import {getIODescription} from 'shared/ReactIODescription'; +import { + getPublicInstance, + getNativeTag, + getCurrentTime, +} from 'react-devtools-shared/src/backend/DevToolsNativeHost'; +import { + isError, + rootSupportsProfiling, + isErrorBoundary, + getSecondaryEnvironmentName, + areEqualRects, +} from './shared/DevToolsFiberInspection'; +import { + didFiberRender, + getContextChanged, + getChangedHooksIndices, + getChangedKeys, +} from './shared/DevToolsFiberChangeDetection'; +import { + ioExistsInSuspenseAncestor, + getAwaitInSuspendedByFromIO, + getVirtualEndTime, +} from './shared/DevToolsFiberSuspense'; + import { getStackByFiberInDevAndProd, getOwnerStackByFiberInDev, @@ -135,13 +157,6 @@ import { supportsConsoleTasks, } from './DevToolsFiberComponentStack'; -// $FlowFixMe[method-unbinding] -const toString = Object.prototype.toString; - -function isError(object: mixed) { - return toString.call(object) === '[object Error]'; -} - import {getStyleXData} from '../StyleX/utils'; import {createProfilingHooks} from '../profilingHooks'; @@ -160,6 +175,7 @@ import type { ProfilingDataBackend, ProfilingDataForRootBackend, ReactRenderer, + Rect, RendererInterface, SerializedElement, SerializedAsyncInfo, @@ -175,30 +191,21 @@ import type { Plugins, } from 'react-devtools-shared/src/frontend/types'; import type {ReactFunctionLocation} from 'shared/ReactTypes'; +import type { + FiberInstance, + FilteredFiberInstance, + VirtualInstance, + DevToolsInstance, + SuspenseNode, +} from './shared/DevToolsFiberTypes'; +import { + FIBER_INSTANCE, + VIRTUAL_INSTANCE, + FILTERED_FIBER_INSTANCE, +} from './shared/DevToolsFiberTypes'; import {getSourceLocationByFiber} from './DevToolsFiberComponentStack'; import {formatOwnerStack} from '../shared/DevToolsOwnerStack'; -// Kinds -const FIBER_INSTANCE = 0; -const VIRTUAL_INSTANCE = 1; -const FILTERED_FIBER_INSTANCE = 2; - -// This type represents a stateful instance of a Client Component i.e. a Fiber pair. -// These instances also let us track stateful DevTools meta data like id and warnings. -type FiberInstance = { - kind: 0, - id: number, - parent: null | DevToolsInstance, - firstChild: null | DevToolsInstance, - nextSibling: null | DevToolsInstance, - source: null | string | Error | ReactFunctionLocation, // source location of this component function, or owned child stack - logCount: number, // total number of errors/warnings last seen - treeBaseDuration: number, // the profiled time of the last render of this subtree - suspendedBy: null | Array, // things that suspended in the children position of this component - suspenseNode: null | SuspenseNode, - data: Fiber, // one of a Fiber pair -}; - function createFiberInstance(fiber: Fiber): FiberInstance { return { kind: FIBER_INSTANCE, @@ -215,22 +222,6 @@ function createFiberInstance(fiber: Fiber): FiberInstance { }; } -type FilteredFiberInstance = { - kind: 2, - // We exclude id from the type to get errors if we try to access it. - // However it is still in the object to preserve hidden class. - // id: number, - parent: null | DevToolsInstance, - firstChild: null | DevToolsInstance, - nextSibling: null | DevToolsInstance, - source: null | string | Error | ReactFunctionLocation, // always null here. - logCount: number, // total number of errors/warnings last seen - treeBaseDuration: number, // the profiled time of the last render of this subtree - suspendedBy: null | Array, // only used at the root - suspenseNode: null | SuspenseNode, - data: Fiber, // one of a Fiber pair -}; - // This is used to represent a filtered Fiber but still lets us find its host instance. function createFilteredFiberInstance(fiber: Fiber): FilteredFiberInstance { return ({ @@ -248,27 +239,6 @@ function createFilteredFiberInstance(fiber: Fiber): FilteredFiberInstance { }: any); } -// This type represents a stateful instance of a Server Component or a Component -// that gets optimized away - e.g. call-through without creating a Fiber. -// It's basically a virtual Fiber. This is not a semantic concept in React. -// It only exists as a virtual concept to let the same Element in the DevTools -// persist. To be selectable separately from all ReactComponentInfo and overtime. -type VirtualInstance = { - kind: 1, - id: number, - parent: null | DevToolsInstance, - firstChild: null | DevToolsInstance, - nextSibling: null | DevToolsInstance, - source: null | string | Error | ReactFunctionLocation, // source location of this server component, or owned child stack - logCount: number, // total number of errors/warnings last seen - treeBaseDuration: number, // the profiled time of the last render of this subtree - suspendedBy: null | Array, // things that blocked the server component's child from rendering - suspenseNode: null, - // The latest info for this instance. This can be updated over time and the - // same info can appear in more than once ServerComponentInstance. - data: ReactComponentInfo, -}; - function createVirtualInstance( debugEntry: ReactComponentInfo, ): VirtualInstance { @@ -287,30 +257,6 @@ function createVirtualInstance( }; } -type DevToolsInstance = FiberInstance | VirtualInstance | FilteredFiberInstance; - -// A Generic Rect super type which can include DOMRect and other objects with similar shape like in React Native. -type Rect = {x: number, y: number, width: number, height: number, ...}; - -type SuspenseNode = { - // The Instance can be a Suspense boundary, a SuspenseList Row, or HostRoot. - // It can also be disconnected from the main tree if it's a Filtered Instance. - instance: FiberInstance | FilteredFiberInstance, - parent: null | SuspenseNode, - firstChild: null | SuspenseNode, - nextSibling: null | SuspenseNode, - rects: null | Array, // The bounding rects of content children. - suspendedBy: Map>, // Tracks which data we're suspended by and the children that suspend it. - environments: Map, // Tracks the Flight environment names that suspended this. I.e. if the server blocked this. - endTime: number, // Track a short cut to the maximum end time value within the suspendedBy set. - // Track whether any of the items in suspendedBy are unique this this Suspense boundaries or if they're all - // also in the parent sets. This determine whether this could contribute in the loading sequence. - hasUniqueSuspenders: boolean, - // Track whether anything suspended in this boundary that we can't track either because it was using throw - // a promise, an older version of React or because we're inspecting prod. - hasUnknownSuspenders: boolean, -}; - // Update flags need to be propagated up until the caller that put the corresponding // node on the stack. // If you push a new node, you need to handle ShouldResetChildren when you pop it. @@ -375,18 +321,6 @@ export function getDispatcherRef(renderer: { return (injectedRef: any); } -function getFiberFlags(fiber: Fiber): number { - // The name of this field changed from "effectTag" to "flags" - return fiber.flags !== undefined ? fiber.flags : (fiber: any).effectTag; -} - -// Some environments (e.g. React Native / Hermes) don't support the performance API yet. -const getCurrentTime = - // $FlowFixMe[method-unbinding] - typeof performance === 'object' && typeof performance.now === 'function' - ? () => performance.now() - : () => Date.now(); - export function getInternalReactConstants(version: string): { getDisplayNameForFiber: getDisplayNameForFiberType, getTypeSymbol: getTypeSymbolType, @@ -883,53 +817,6 @@ const hostResourceToDevToolsInstanceMap: Map< Set, > = new Map(); -// Ideally, this should be injected from Reconciler config -function getPublicInstance(instance: HostInstance): HostInstance { - // Typically the PublicInstance and HostInstance is the same thing but not in Fabric. - // So we need to detect this and use that as the public instance. - - // React Native. Modern. Fabric. - if (typeof instance === 'object' && instance !== null) { - if (typeof instance.canonical === 'object' && instance.canonical !== null) { - if ( - typeof instance.canonical.publicInstance === 'object' && - instance.canonical.publicInstance !== null - ) { - return instance.canonical.publicInstance; - } - } - - // React Native. Legacy. Paper. - if (typeof instance._nativeTag === 'number') { - return instance._nativeTag; - } - } - - // React Web. Usually a DOM element. - return instance; -} - -function getNativeTag(instance: HostInstance): number | null { - if (typeof instance !== 'object' || instance === null) { - return null; - } - - // Modern. Fabric. - if ( - instance.canonical != null && - typeof instance.canonical.nativeTag === 'number' - ) { - return instance.canonical.nativeTag; - } - - // Legacy. Paper. - if (typeof instance._nativeTag === 'number') { - return instance._nativeTag; - } - - return null; -} - function aquireHostInstance( nearestInstance: DevToolsInstance, hostInstance: HostInstance, @@ -1029,7 +916,6 @@ export function attach( const { ActivityComponent, ClassComponent, - ContextConsumer, DehydratedSuspenseComponent, ForwardRef, Fragment, @@ -1992,134 +1878,6 @@ export function attach( } } - function getContextChanged(prevFiber: Fiber, nextFiber: Fiber): boolean { - let prevContext = - prevFiber.dependencies && prevFiber.dependencies.firstContext; - let nextContext = - nextFiber.dependencies && nextFiber.dependencies.firstContext; - - while (prevContext && nextContext) { - // Note this only works for versions of React that support this key (e.v. 18+) - // For older versions, there's no good way to read the current context value after render has completed. - // This is because React maintains a stack of context values during render, - // but by the time DevTools is called, render has finished and the stack is empty. - if (prevContext.context !== nextContext.context) { - // If the order of context has changed, then the later context values might have - // changed too but the main reason it rerendered was earlier. Either an earlier - // context changed value but then we would have exited already. If we end up here - // it's because a state or props change caused the order of contexts used to change. - // So the main cause is not the contexts themselves. - return false; - } - if (!is(prevContext.memoizedValue, nextContext.memoizedValue)) { - return true; - } - - prevContext = prevContext.next; - nextContext = nextContext.next; - } - return false; - } - - function didStatefulHookChange(prev: HooksNode, next: HooksNode): boolean { - // Detect the shape of useState() / useReducer() / useTransition() / useSyncExternalStore() / useActionState() - const isStatefulHook = - prev.isStateEditable === true || - prev.name === 'SyncExternalStore' || - prev.name === 'Transition' || - prev.name === 'ActionState' || - prev.name === 'FormState'; - - // Compare the values to see if they changed - if (isStatefulHook) { - return prev.value !== next.value; - } - - return false; - } - - function getChangedHooksIndices( - prevHooks: HooksTree | null, - nextHooks: HooksTree | null, - ): null | Array { - if (prevHooks == null || nextHooks == null) { - return null; - } - - const indices: Array = []; - let index = 0; - - function traverse(prevTree: HooksTree, nextTree: HooksTree): void { - for (let i = 0; i < prevTree.length; i++) { - const prevHook = prevTree[i]; - const nextHook = nextTree[i]; - - if (prevHook.subHooks.length > 0 && nextHook.subHooks.length > 0) { - traverse(prevHook.subHooks, nextHook.subHooks); - continue; - } - - if (didStatefulHookChange(prevHook, nextHook)) { - indices.push(index); - } - - index++; - } - } - - traverse(prevHooks, nextHooks); - return indices; - } - - function getChangedKeys(prev: any, next: any): null | Array { - if (prev == null || next == null) { - return null; - } - - const keys = new Set([...Object.keys(prev), ...Object.keys(next)]); - const changedKeys = []; - // eslint-disable-next-line no-for-of-loops/no-for-of-loops - for (const key of keys) { - if (prev[key] !== next[key]) { - changedKeys.push(key); - } - } - - return changedKeys; - } - - /** - * Returns true iff nextFiber actually performed any work and produced an update. - * For generic components, like Function or Class components, prevFiber is not considered. - */ - function didFiberRender(prevFiber: Fiber, nextFiber: Fiber): boolean { - switch (nextFiber.tag) { - case ClassComponent: - case FunctionComponent: - case ContextConsumer: - case MemoComponent: - case SimpleMemoComponent: - case ForwardRef: - // For types that execute user code, we check PerformedWork effect. - // We don't reflect bailouts (either referential or sCU) in DevTools. - // TODO: This flag is a leaked implementation detail. Once we start - // releasing DevTools in lockstep with React, we should import a - // function from the reconciler instead. - const PerformedWork = 0b000000000000000000000000001; - return (getFiberFlags(nextFiber) & PerformedWork) === PerformedWork; - // Note: ContextConsumer only gets PerformedWork effect in 16.3.3+ - // so it won't get highlighted with React 16.3.0 to 16.3.2. - default: - // For host components and other types, we compare inputs - // to determine whether something is an update. - return ( - prevFiber.memoizedProps !== nextFiber.memoizedProps || - prevFiber.memoizedState !== nextFiber.memoizedState || - prevFiber.ref !== nextFiber.ref - ); - } - } - type OperationsArray = Array; type StringTableEntry = { @@ -2943,20 +2701,6 @@ export function attach( // the current parent here as well. let reconcilingParentSuspenseNode: null | SuspenseNode = null; - function ioExistsInSuspenseAncestor( - suspenseNode: SuspenseNode, - ioInfo: ReactIOInfo, - ): boolean { - let ancestor = suspenseNode.parent; - while (ancestor !== null) { - if (ancestor.suspendedBy.has(ioInfo)) { - return true; - } - ancestor = ancestor.parent; - } - return false; - } - function insertSuspendedBy(asyncInfo: ReactAsyncInfo): void { if (reconcilingParent === null || reconcilingParentSuspenseNode === null) { throw new Error( @@ -3055,19 +2799,6 @@ export function attach( } } - function getAwaitInSuspendedByFromIO( - suspensedBy: Array, - ioInfo: ReactIOInfo, - ): null | ReactAsyncInfo { - for (let i = 0; i < suspensedBy.length; i++) { - const asyncInfo = suspensedBy[i]; - if (asyncInfo.awaited === ioInfo) { - return asyncInfo; - } - } - return null; - } - function unblockSuspendedBy( parentSuspenseNode: SuspenseNode, ioInfo: ReactIOInfo, @@ -3101,15 +2832,6 @@ export function attach( } } - function getVirtualEndTime(ioInfo: ReactIOInfo): number { - if (ioInfo.env != null) { - // Sort client side content first so that scripts and streams don't - // cover up the effect of server time. - return ioInfo.end + 1000000; - } - return ioInfo.end; - } - function computeEndTime(suspenseNode: SuspenseNode) { let maxEndTime = 0; suspenseNode.suspendedBy.forEach((set, ioInfo) => { @@ -3432,34 +3154,6 @@ export function attach( return false; } - function areEqualRects( - a: null | Array, - b: null | Array, - ): boolean { - if (a === null) { - return b === null; - } - if (b === null) { - return false; - } - if (a.length !== b.length) { - return false; - } - for (let i = 0; i < a.length; i++) { - const aRect = a[i]; - const bRect = b[i]; - if ( - aRect.x !== bRect.x || - aRect.y !== bRect.y || - aRect.width !== bRect.width || - aRect.height !== bRect.height - ) { - return false; - } - } - return true; - } - function measureUnchangedSuspenseNodesRecursively( suspenseNode: SuspenseNode, ): void { @@ -3632,25 +3326,6 @@ export function attach( pendingRealUnmountedIDs.push(id); } - function getSecondaryEnvironmentName( - debugInfo: ?ReactDebugInfo, - index: number, - ): null | string { - if (debugInfo != null) { - const componentInfo: ReactComponentInfo = (debugInfo[index]: any); - for (let i = index + 1; i < debugInfo.length; i++) { - const debugEntry = debugInfo[i]; - if (typeof debugEntry.env === 'string') { - // If the next environment is different then this component was the boundary - // and it changed before entering the next component. So we assign this - // component a secondary environment. - return componentInfo.env !== debugEntry.env ? debugEntry.env : null; - } - } - } - return null; - } - function trackDebugInfoFromLazyType(fiber: Fiber): void { // The debugInfo from a Lazy isn't propagated onto _debugInfo of the parent Fiber the way // it is when used in child position. So we need to pick it up explicitly. @@ -4532,7 +4207,8 @@ export function attach( if ( prevFiber == null || - (prevFiber !== fiber && didFiberRender(prevFiber, fiber)) + (prevFiber !== fiber && + didFiberRender(ReactTypeOfWork, prevFiber, fiber)) ) { if (actualDuration != null) { // The actual duration reported by React includes time spent working on children. @@ -5166,6 +4842,7 @@ export function attach( if (prevFiber !== nextFiber) { // Otherwise if this is a traced ancestor, flag for the nearest host descendant(s). traceNearestHostComponentUpdate = didFiberRender( + ReactTypeOfWork, prevFiber, nextFiber, ); @@ -5197,7 +4874,7 @@ export function attach( // Invalidating any Root invalidates the Screen too. (mostRecentlyInspectedElement.type === ElementTypeRoot && nextFiber.tag === HostRoot)) && - didFiberRender(prevFiber, nextFiber) + didFiberRender(ReactTypeOfWork, prevFiber, nextFiber) ) { // If this Fiber has updated, clear cached inspected data. // If it is inspected again, it may need to be re-run to obtain updated hooks values. @@ -5720,22 +5397,6 @@ export function attach( isProfiling = false; } - function rootSupportsProfiling(root: any) { - if (root.memoizedInteractions != null) { - // v16 builds include this field for the scheduler/tracing API. - return true; - } else if ( - root.current != null && - root.current.hasOwnProperty('treeBaseDuration') - ) { - // The scheduler/tracing API was removed in v17 though - // so we need to check a non-root Fiber. - return true; - } else { - return false; - } - } - function flushInitialOperations() { const localPendingOperationsQueue = pendingOperationsQueue; @@ -6804,23 +6465,6 @@ export function attach( return {instance, style}; } - function isErrorBoundary(fiber: Fiber): boolean { - const {tag, type} = fiber; - - switch (tag) { - case ClassComponent: - case IncompleteClassComponent: - const instance = fiber.stateNode; - return ( - typeof type.getDerivedStateFromError === 'function' || - (instance !== null && - typeof instance.componentDidCatch === 'function') - ); - default: - return false; - } - } - function inspectElementRaw(id: number): InspectedElement | null { const devtoolsInstance = idToDevToolsInstanceMap.get(id); if (devtoolsInstance === undefined) { @@ -6995,7 +6639,7 @@ export function attach( current = current.return; if (temp.tag === SuspenseComponent) { hasSuspenseBoundary = true; - } else if (isErrorBoundary(temp)) { + } else if (isErrorBoundary(ReactTypeOfWork, temp)) { hasErrorBoundary = true; } } @@ -7005,7 +6649,7 @@ export function attach( } let isErrored = false; - if (isErrorBoundary(fiber)) { + if (isErrorBoundary(ReactTypeOfWork, fiber)) { // if the current inspected element is an error boundary, // either that we want to use it to toggle off error state // or that we allow to force error state on it if it's within another @@ -7197,7 +6841,7 @@ export function attach( current = current.return; if (temp.tag === SuspenseComponent) { hasSuspenseBoundary = true; - } else if (isErrorBoundary(temp)) { + } else if (isErrorBoundary(ReactTypeOfWork, temp)) { hasErrorBoundary = true; } } @@ -8314,7 +7958,7 @@ export function attach( return; } let fiber = nearestFiber; - while (!isErrorBoundary(fiber)) { + while (!isErrorBoundary(ReactTypeOfWork, fiber)) { if (fiber.return === null) { return; } diff --git a/packages/react-devtools-shared/src/backend/fiber/shared/DevToolsFiberChangeDetection.js b/packages/react-devtools-shared/src/backend/fiber/shared/DevToolsFiberChangeDetection.js new file mode 100644 index 000000000000..0feef5803e92 --- /dev/null +++ b/packages/react-devtools-shared/src/backend/fiber/shared/DevToolsFiberChangeDetection.js @@ -0,0 +1,150 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {Fiber} from 'react-reconciler/src/ReactInternalTypes'; +import type {HooksNode, HooksTree} from 'react-debug-tools/src/ReactDebugHooks'; +import type {WorkTagMap} from '../../types'; + +import {getFiberFlags} from './DevToolsFiberInspection'; +import is from 'shared/objectIs'; + +export function getContextChanged(prevFiber: Fiber, nextFiber: Fiber): boolean { + let prevContext = + prevFiber.dependencies && prevFiber.dependencies.firstContext; + let nextContext = + nextFiber.dependencies && nextFiber.dependencies.firstContext; + + while (prevContext && nextContext) { + // Note this only works for versions of React that support this key (e.v. 18+) + // For older versions, there's no good way to read the current context value after render has completed. + // This is because React maintains a stack of context values during render, + // but by the time DevTools is called, render has finished and the stack is empty. + if (prevContext.context !== nextContext.context) { + // If the order of context has changed, then the later context values might have + // changed too but the main reason it rerendered was earlier. Either an earlier + // context changed value but then we would have exited already. If we end up here + // it's because a state or props change caused the order of contexts used to change. + // So the main cause is not the contexts themselves. + return false; + } + if (!is(prevContext.memoizedValue, nextContext.memoizedValue)) { + return true; + } + + prevContext = prevContext.next; + nextContext = nextContext.next; + } + return false; +} + +export function didStatefulHookChange( + prev: HooksNode, + next: HooksNode, +): boolean { + // Detect the shape of useState() / useReducer() / useTransition() / useSyncExternalStore() / useActionState() + const isStatefulHook = + prev.isStateEditable === true || + prev.name === 'SyncExternalStore' || + prev.name === 'Transition' || + prev.name === 'ActionState' || + prev.name === 'FormState'; + + // Compare the values to see if they changed + if (isStatefulHook) { + return prev.value !== next.value; + } + + return false; +} + +export function getChangedHooksIndices( + prevHooks: HooksTree | null, + nextHooks: HooksTree | null, +): null | Array { + if (prevHooks == null || nextHooks == null) { + return null; + } + + const indices: Array = []; + let index = 0; + + function traverse(prevTree: HooksTree, nextTree: HooksTree): void { + for (let i = 0; i < prevTree.length; i++) { + const prevHook = prevTree[i]; + const nextHook = nextTree[i]; + + if (prevHook.subHooks.length > 0 && nextHook.subHooks.length > 0) { + traverse(prevHook.subHooks, nextHook.subHooks); + continue; + } + + if (didStatefulHookChange(prevHook, nextHook)) { + indices.push(index); + } + + index++; + } + } + + traverse(prevHooks, nextHooks); + return indices; +} + +export function getChangedKeys(prev: any, next: any): null | Array { + if (prev == null || next == null) { + return null; + } + + const keys = new Set([...Object.keys(prev), ...Object.keys(next)]); + const changedKeys = []; + // eslint-disable-next-line no-for-of-loops/no-for-of-loops + for (const key of keys) { + if (prev[key] !== next[key]) { + changedKeys.push(key); + } + } + + return changedKeys; +} + +/** + * Returns true iff nextFiber actually performed any work and produced an update. + * For generic components, like Function or Class components, prevFiber is not considered. + */ +export function didFiberRender( + workTagMap: WorkTagMap, + prevFiber: Fiber, + nextFiber: Fiber, +): boolean { + switch (nextFiber.tag) { + case workTagMap.ClassComponent: + case workTagMap.FunctionComponent: + case workTagMap.ContextConsumer: + case workTagMap.MemoComponent: + case workTagMap.SimpleMemoComponent: + case workTagMap.ForwardRef: + // For types that execute user code, we check PerformedWork effect. + // We don't reflect bailouts (either referential or sCU) in DevTools. + // TODO: This flag is a leaked implementation detail. Once we start + // releasing DevTools in lockstep with React, we should import a + // function from the reconciler instead. + const PerformedWork = 0b000000000000000000000000001; + return (getFiberFlags(nextFiber) & PerformedWork) === PerformedWork; + // Note: ContextConsumer only gets PerformedWork effect in 16.3.3+ + // so it won't get highlighted with React 16.3.0 to 16.3.2. + default: + // For host components and other types, we compare inputs + // to determine whether something is an update. + return ( + prevFiber.memoizedProps !== nextFiber.memoizedProps || + prevFiber.memoizedState !== nextFiber.memoizedState || + prevFiber.ref !== nextFiber.ref + ); + } +} diff --git a/packages/react-devtools-shared/src/backend/fiber/shared/DevToolsFiberInspection.js b/packages/react-devtools-shared/src/backend/fiber/shared/DevToolsFiberInspection.js new file mode 100644 index 000000000000..58c86f7cac09 --- /dev/null +++ b/packages/react-devtools-shared/src/backend/fiber/shared/DevToolsFiberInspection.js @@ -0,0 +1,104 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {ReactComponentInfo, ReactDebugInfo} from 'shared/ReactTypes'; +import type {Fiber} from 'react-reconciler/src/ReactInternalTypes'; +import type {WorkTagMap} from '../../types'; +import type {Rect} from '../../types'; + +// $FlowFixMe[method-unbinding] +const toString = Object.prototype.toString; + +export function isError(object: mixed): boolean { + return toString.call(object) === '[object Error]'; +} + +export function getFiberFlags(fiber: Fiber): number { + // The name of this field changed from "effectTag" to "flags" + return fiber.flags !== undefined ? fiber.flags : (fiber: any).effectTag; +} + +export function rootSupportsProfiling(root: any): boolean { + if (root.memoizedInteractions != null) { + // v16 builds include this field for the scheduler/tracing API. + return true; + } else if ( + root.current != null && + root.current.hasOwnProperty('treeBaseDuration') + ) { + // The scheduler/tracing API was removed in v17 though + // so we need to check a non-root Fiber. + return true; + } else { + return false; + } +} + +export function isErrorBoundary(workTagMap: WorkTagMap, fiber: Fiber): boolean { + const {tag, type} = fiber; + + switch (tag) { + case workTagMap.ClassComponent: + case workTagMap.IncompleteClassComponent: + const instance = fiber.stateNode; + return ( + typeof type.getDerivedStateFromError === 'function' || + (instance !== null && typeof instance.componentDidCatch === 'function') + ); + default: + return false; + } +} + +export function getSecondaryEnvironmentName( + debugInfo: ?ReactDebugInfo, + index: number, +): null | string { + if (debugInfo != null) { + const componentInfo: ReactComponentInfo = (debugInfo[index]: any); + for (let i = index + 1; i < debugInfo.length; i++) { + const debugEntry = debugInfo[i]; + if (typeof debugEntry.env === 'string') { + // If the next environment is different then this component was the boundary + // and it changed before entering the next component. So we assign this + // component a secondary environment. + return componentInfo.env !== debugEntry.env ? debugEntry.env : null; + } + } + } + return null; +} + +export function areEqualRects( + a: null | Array, + b: null | Array, +): boolean { + if (a === null) { + return b === null; + } + if (b === null) { + return false; + } + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + const aRect = a[i]; + const bRect = b[i]; + if ( + aRect.x !== bRect.x || + aRect.y !== bRect.y || + aRect.width !== bRect.width || + aRect.height !== bRect.height + ) { + return false; + } + } + return true; +} diff --git a/packages/react-devtools-shared/src/backend/fiber/shared/DevToolsFiberSuspense.js b/packages/react-devtools-shared/src/backend/fiber/shared/DevToolsFiberSuspense.js new file mode 100644 index 000000000000..b0b64b184c22 --- /dev/null +++ b/packages/react-devtools-shared/src/backend/fiber/shared/DevToolsFiberSuspense.js @@ -0,0 +1,47 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {ReactIOInfo, ReactAsyncInfo} from 'shared/ReactTypes'; +import type {SuspenseNode} from './DevToolsFiberTypes'; + +export function ioExistsInSuspenseAncestor( + suspenseNode: SuspenseNode, + ioInfo: ReactIOInfo, +): boolean { + let ancestor = suspenseNode.parent; + while (ancestor !== null) { + if (ancestor.suspendedBy.has(ioInfo)) { + return true; + } + ancestor = ancestor.parent; + } + return false; +} + +export function getAwaitInSuspendedByFromIO( + suspensedBy: Array, + ioInfo: ReactIOInfo, +): null | ReactAsyncInfo { + for (let i = 0; i < suspensedBy.length; i++) { + const asyncInfo = suspensedBy[i]; + if (asyncInfo.awaited === ioInfo) { + return asyncInfo; + } + } + return null; +} + +export function getVirtualEndTime(ioInfo: ReactIOInfo): number { + if (ioInfo.env != null) { + // Sort client side content first so that scripts and streams don't + // cover up the effect of server time. + return ioInfo.end + 1000000; + } + return ioInfo.end; +} diff --git a/packages/react-devtools-shared/src/backend/fiber/shared/DevToolsFiberTypes.js b/packages/react-devtools-shared/src/backend/fiber/shared/DevToolsFiberTypes.js new file mode 100644 index 000000000000..1b01dfbc5bbc --- /dev/null +++ b/packages/react-devtools-shared/src/backend/fiber/shared/DevToolsFiberTypes.js @@ -0,0 +1,99 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {Fiber} from 'react-reconciler/src/ReactInternalTypes'; +import type { + ReactComponentInfo, + ReactAsyncInfo, + ReactIOInfo, + ReactFunctionLocation, +} from 'shared/ReactTypes'; +import type {Rect} from '../../types'; + +// Kinds +export const FIBER_INSTANCE = 0; +export const VIRTUAL_INSTANCE = 1; +export const FILTERED_FIBER_INSTANCE = 2; + +// This type represents a stateful instance of a Client Component i.e. a Fiber pair. +// These instances also let us track stateful DevTools meta data like id and warnings. +export type FiberInstance = { + kind: 0, + id: number, + parent: null | DevToolsInstance, + firstChild: null | DevToolsInstance, + nextSibling: null | DevToolsInstance, + source: null | string | Error | ReactFunctionLocation, // source location of this component function, or owned child stack + logCount: number, // total number of errors/warnings last seen + treeBaseDuration: number, // the profiled time of the last render of this subtree + suspendedBy: null | Array, // things that suspended in the children position of this component + suspenseNode: null | SuspenseNode, + data: Fiber, // one of a Fiber pair +}; + +export type FilteredFiberInstance = { + kind: 2, + // We exclude id from the type to get errors if we try to access it. + // However it is still in the object to preserve hidden class. + // id: number, + parent: null | DevToolsInstance, + firstChild: null | DevToolsInstance, + nextSibling: null | DevToolsInstance, + source: null | string | Error | ReactFunctionLocation, // always null here. + logCount: number, // total number of errors/warnings last seen + treeBaseDuration: number, // the profiled time of the last render of this subtree + suspendedBy: null | Array, // only used at the root + suspenseNode: null | SuspenseNode, + data: Fiber, // one of a Fiber pair +}; + +// This type represents a stateful instance of a Server Component or a Component +// that gets optimized away - e.g. call-through without creating a Fiber. +// It's basically a virtual Fiber. This is not a semantic concept in React. +// It only exists as a virtual concept to let the same Element in the DevTools +// persist. To be selectable separately from all ReactComponentInfo and overtime. +export type VirtualInstance = { + kind: 1, + id: number, + parent: null | DevToolsInstance, + firstChild: null | DevToolsInstance, + nextSibling: null | DevToolsInstance, + source: null | string | Error | ReactFunctionLocation, // source location of this server component, or owned child stack + logCount: number, // total number of errors/warnings last seen + treeBaseDuration: number, // the profiled time of the last render of this subtree + suspendedBy: null | Array, // things that blocked the server component's child from rendering + suspenseNode: null, + // The latest info for this instance. This can be updated over time and the + // same info can appear in more than once ServerComponentInstance. + data: ReactComponentInfo, +}; + +export type DevToolsInstance = + | FiberInstance + | VirtualInstance + | FilteredFiberInstance; + +export type SuspenseNode = { + // The Instance can be a Suspense boundary, a SuspenseList Row, or HostRoot. + // It can also be disconnected from the main tree if it's a Filtered Instance. + instance: FiberInstance | FilteredFiberInstance, + parent: null | SuspenseNode, + firstChild: null | SuspenseNode, + nextSibling: null | SuspenseNode, + rects: null | Array, // The bounding rects of content children. + suspendedBy: Map>, // Tracks which data we're suspended by and the children that suspend it. + environments: Map, // Tracks the Flight environment names that suspended this. I.e. if the server blocked this. + endTime: number, // Track a short cut to the maximum end time value within the suspendedBy set. + // Track whether any of the items in suspendedBy are unique this this Suspense boundaries or if they're all + // also in the parent sets. This determine whether this could contribute in the loading sequence. + hasUniqueSuspenders: boolean, + // Track whether anything suspended in this boundary that we can't track either because it was using throw + // a promise, an older version of React or because we're inspecting prod. + hasUnknownSuspenders: boolean, +}; diff --git a/packages/react-devtools-shared/src/backend/types.js b/packages/react-devtools-shared/src/backend/types.js index a97439b7cf98..c68601f9b633 100644 --- a/packages/react-devtools-shared/src/backend/types.js +++ b/packages/react-devtools-shared/src/backend/types.js @@ -101,7 +101,7 @@ export type FindHostInstancesForElementID = ( id: number, ) => null | $ReadOnlyArray; -type Rect = { +export type Rect = { x: number, y: number, width: number,