diff --git a/packages/react-art/src/ReactARTHostConfig.js b/packages/react-art/src/ReactARTHostConfig.js index 47bced8a1274a..9ac1e62a8b7d8 100644 --- a/packages/react-art/src/ReactARTHostConfig.js +++ b/packages/react-art/src/ReactARTHostConfig.js @@ -451,3 +451,15 @@ export function preparePortalMount(portalInstance: any): void { export function detachDeletedInstance(node: Instance): void { // noop } + +export function getCurrentEventStartTime() { + // noop +} + +export function scheduleTransitionCallbacks( + callback, + pendingTransitions, + callbacks, +) { + // noop +} diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index 895e75359867c..dfbe5a1ade4d0 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -13,6 +13,7 @@ import type { MutableSourceSubscribeFn, ReactContext, ReactProviderType, + StartTransitionOptions, } from 'shared/ReactTypes'; import type { Fiber, @@ -290,7 +291,10 @@ function useSyncExternalStore( return value; } -function useTransition(): [boolean, (() => void) => void] { +function useTransition(): [ + boolean, + (callback: () => void, options?: StartTransitionOptions) => void, +] { // useTransition() composes multiple hooks internally. // Advance the current hook index the same number of times // so that subsequent hooks have the right memoized state. diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js index 08cd8cb81c787..5fc08c9f78412 100644 --- a/packages/react-dom/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom/src/client/ReactDOMHostConfig.js @@ -8,13 +8,18 @@ */ import type {DOMEventName} from '../events/DOMEventNames'; -import type {Fiber, FiberRoot} from 'react-reconciler/src/ReactInternalTypes'; +import type { + Fiber, + FiberRoot, + TransitionTracingCallbacks, +} from 'react-reconciler/src/ReactInternalTypes'; import type { BoundingRect, IntersectionObserverOptions, ObserveVisibleRectsCallback, } from 'react-reconciler/src/ReactTestSelectors'; import type {ReactScopeInstance} from 'shared/ReactTypes'; +import type {TransitionCallbackObject} from 'react-reconciler/src/ReactFiberTracingMarkerComponent.new'; import { precacheFiberNode, @@ -70,6 +75,11 @@ import {HostComponent, HostText} from 'react-reconciler/src/ReactWorkTags'; import {listenToAllSupportedEvents} from '../events/DOMPluginEventSystem'; import {DefaultEventPriority} from 'react-reconciler/src/ReactEventPriorities'; +import { + now, + scheduleCallback, + IdlePriority, +} from 'react-reconciler/src/Scheduler'; export type Type = string; export type Props = { @@ -1243,3 +1253,42 @@ export function setupIntersectionObserver( }, }; } + +let currentFrameTime = null; +export function getCurrentEventStartTime(): number { + const event = window.event; + if (event != null) { + return event.timeStamp; + } + + if (currentFrameTime === null) { + currentFrameTime = performance.now(); + + setTimeout(() => { + currentFrameTime = null; + }, 0); + } + + return currentFrameTime; +} + +// TODO(luna) Fix this because rAF doesn't actually do +// what you want here +export function scheduleTransitionCallbacks( + callback: ( + Array, + endTime: number, + callbacks: TransitionTracingCallbacks, + ) => void, + // TODO(luna) Figure out type of this + pendingTransitions: Array, + callbacks: TransitionTracingCallbacks, +): void { + window.requestAnimationFrame(() => { + const endTime = now(); + + scheduleCallback(IdlePriority, () => { + callback(pendingTransitions, endTime, callbacks); + }); + }); +} diff --git a/packages/react-dom/src/events/ReactDOMEventListener.js b/packages/react-dom/src/events/ReactDOMEventListener.js index f228090221ff0..735987d58891d 100644 --- a/packages/react-dom/src/events/ReactDOMEventListener.js +++ b/packages/react-dom/src/events/ReactDOMEventListener.js @@ -11,7 +11,10 @@ import type {AnyNativeEvent} from '../events/PluginModuleType'; import type {FiberRoot} from 'react-reconciler/src/ReactInternalTypes'; import type {Container, SuspenseInstance} from '../client/ReactDOMHostConfig'; import type {DOMEventName} from '../events/DOMEventNames'; -import {enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay} from 'shared/ReactFeatureFlags'; +import { + enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay, + enableTransitionTracing, +} from 'shared/ReactFeatureFlags'; import { isDiscreteEventThatRequiresHydration, queueDiscreteEvent, @@ -118,12 +121,23 @@ function dispatchDiscreteEvent( const previousPriority = getCurrentUpdatePriority(); const prevTransition = ReactCurrentBatchConfig.transition; ReactCurrentBatchConfig.transition = 0; + + let prevTransitionInfo = null; + if (enableTransitionTracing) { + prevTransitionInfo = ReactCurrentBatchConfig.transitionInfo; + ReactCurrentBatchConfig.transitionInfo = null; + } + try { setCurrentUpdatePriority(DiscreteEventPriority); dispatchEvent(domEventName, eventSystemFlags, container, nativeEvent); } finally { setCurrentUpdatePriority(previousPriority); ReactCurrentBatchConfig.transition = prevTransition; + + if (enableTransitionTracing) { + ReactCurrentBatchConfig.transitionInfo = prevTransitionInfo; + } } } @@ -136,12 +150,23 @@ function dispatchContinuousEvent( const previousPriority = getCurrentUpdatePriority(); const prevTransition = ReactCurrentBatchConfig.transition; ReactCurrentBatchConfig.transition = 0; + + let prevTransitionInfo = null; + if (enableTransitionTracing) { + prevTransitionInfo = ReactCurrentBatchConfig.transitionInfo; + ReactCurrentBatchConfig.transitionInfo = null; + } + try { setCurrentUpdatePriority(ContinuousEventPriority); dispatchEvent(domEventName, eventSystemFlags, container, nativeEvent); } finally { setCurrentUpdatePriority(previousPriority); ReactCurrentBatchConfig.transition = prevTransition; + + if (enableTransitionTracing) { + ReactCurrentBatchConfig.transitionInfo = prevTransitionInfo; + } } } diff --git a/packages/react-native-renderer/src/ReactFabricHostConfig.js b/packages/react-native-renderer/src/ReactFabricHostConfig.js index 727b782efd768..55a0b4f469a72 100644 --- a/packages/react-native-renderer/src/ReactFabricHostConfig.js +++ b/packages/react-native-renderer/src/ReactFabricHostConfig.js @@ -525,3 +525,15 @@ export function preparePortalMount(portalInstance: Instance): void { export function detachDeletedInstance(node: Instance): void { // noop } + +export function getCurrentEventStartTime() { + // noop +} + +export function scheduleTransitionCallbacks( + callback, + pendingTransitions, + callbacks, +) { + // noop +} diff --git a/packages/react-native-renderer/src/ReactNativeHostConfig.js b/packages/react-native-renderer/src/ReactNativeHostConfig.js index 10c5e37f41bcc..6107ee1f23a6f 100644 --- a/packages/react-native-renderer/src/ReactNativeHostConfig.js +++ b/packages/react-native-renderer/src/ReactNativeHostConfig.js @@ -513,3 +513,15 @@ export function preparePortalMount(portalInstance: Instance): void { export function detachDeletedInstance(node: Instance): void { // noop } + +export function getCurrentEventStartTime() { + // noop +} + +export function scheduleTransitionCallbacks( + callback, + pendingTransitions, + callbacks, +) { + // noop +} diff --git a/packages/react-noop-renderer/src/createReactNoop.js b/packages/react-noop-renderer/src/createReactNoop.js index e0ba72076a1af..b56e94cb6c881 100644 --- a/packages/react-noop-renderer/src/createReactNoop.js +++ b/packages/react-noop-renderer/src/createReactNoop.js @@ -21,6 +21,7 @@ import type { import type {UpdateQueue} from 'react-reconciler/src/ReactUpdateQueue'; import type {ReactNodeList, OffscreenMode} from 'shared/ReactTypes'; import type {RootTag} from 'react-reconciler/src/ReactRootTags'; +import type {TransitionCallbackObject} from 'react-reconciler/src/ReactFiberTracingMarkerComponent.new'; import * as Scheduler from 'scheduler/unstable_mock'; import {REACT_FRAGMENT_TYPE, REACT_ELEMENT_TYPE} from 'shared/ReactSymbols'; @@ -477,6 +478,25 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { logRecoverableError() { // no-op }, + + getCurrentEventStartTime(): number { + return Scheduler.unstable_now(); + }, + + scheduleTransitionCallbacks( + callback: ( + Array, + endTime: number, + callbacks: TransitionTracingCallbacks, + ) => void, + pendingTransitions: Array, + callbacks: TransitionTracingCallbacks, + ): void { + const endTime = Scheduler.unstable_now(); + Scheduler.unstable_scheduleCallback(Scheduler.unstable_IdlePriority, () => + callback(pendingTransitions, endTime, callbacks), + ); + }, }; const hostConfig = useMutation diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.new.js b/packages/react-reconciler/src/ReactFiberBeginWork.new.js index 521a75d11dcb7..94d8db7651ba1 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.new.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.new.js @@ -28,6 +28,7 @@ import type { CacheComponentState, SpawnedCachePool, } from './ReactFiberCacheComponent.new'; +import type {TracingMarkerState} from './ReactFiberTracingMarkerComponent.new'; import type {UpdateQueue} from './ReactUpdateQueue.new'; import {enableSuspenseAvoidThisFallback} from 'shared/ReactFeatureFlags'; @@ -228,6 +229,7 @@ import { getExecutionContext, RetryAfterError, NoContext, + generateNewSuspenseOffscreenID, } from './ReactFiberWorkLoop.new'; import {setWorkInProgressVersion} from './ReactMutableSource.new'; import { @@ -249,6 +251,14 @@ import { pushTreeId, pushMaterializedTreeId, } from './ReactFiberTreeContext.new'; +import { + getSuspendedTransitionPool, + getSuspendedTracingMarkersPool, + pushRootTransitionPool, + pushRootTracingMarkersPool, + pushTracingMarkersPool, + pushTransitionPool, +} from './ReactFiberTracingMarkerComponent.new'; const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner; @@ -652,6 +662,7 @@ function updateOffscreenComponent( const nextState: OffscreenState = { baseLanes: NoLanes, cachePool: null, + transitions: null, }; workInProgress.memoizedState = nextState; pushRenderLanes(workInProgress, renderLanes); @@ -664,6 +675,12 @@ function updateOffscreenComponent( nextBaseLanes = mergeLanes(prevBaseLanes, renderLanes); if (enableCache) { // Save the cache pool so we can resume later. + const prevCachePool = prevState.cachePool; + if (prevCachePool !== null) { + // push the cache pool even though we're going to bail out + // because otherwise there'd be a context mismatch + restoreSpawnedCachePool(workInProgress, prevCachePool); + } spawnedCachePool = getOffscreenDeferredCachePool(); // We don't need to push to the cache pool because we're about to // bail out. There won't be a context mismatch because we only pop @@ -680,6 +697,7 @@ function updateOffscreenComponent( const nextState: OffscreenState = { baseLanes: nextBaseLanes, cachePool: spawnedCachePool, + transitions: null, }; workInProgress.memoizedState = nextState; workInProgress.updateQueue = null; @@ -720,6 +738,7 @@ function updateOffscreenComponent( const nextState: OffscreenState = { baseLanes: NoLanes, cachePool: null, + transitions: null, }; workInProgress.memoizedState = nextState; // Push the lanes that were skipped when we bailed out. @@ -748,6 +767,15 @@ function updateOffscreenComponent( } } + if (enableTransitionTracing) { + const transitions = prevState.transitions; + + if (transitions !== null) { + // This suspense boundary has rendered, so push the transitions onto the stack + pushTransitionPool(workInProgress, transitions); + } + } + // Since we're not hidden anymore, reset the state workInProgress.memoizedState = null; } else { @@ -759,12 +787,6 @@ function updateOffscreenComponent( pushRenderLanes(workInProgress, subtreeRenderLanes); } - if (enableCache) { - // If we have a cache pool from a previous render attempt, then this will be - // non-null. We use this to infer whether to push/pop the cache context. - workInProgress.updateQueue = spawnedCachePool; - } - if (enablePersistentOffscreenHostContainer && supportsPersistence) { // In persistent mode, the offscreen children are wrapped in a host node. // TODO: Optimize this to use the OffscreenComponent fiber instead of @@ -910,6 +932,49 @@ function updateTracingMarkerComponent( return null; } + const nextProps = workInProgress.pendingProps; + if (__DEV__) { + if (nextProps.name === null) { + console.error('Tracing marker name must be defined'); + } + } + + const transitions = new Set(); + let pendingSuspenseBoundaries = null; + + // Add all current transitions on the stack to the tracing marker + const currentTransitions = getSuspendedTransitionPool(); + if (currentTransitions !== null && currentTransitions.size > 0) { + currentTransitions.forEach(transition => { + transitions.add(transition); + }); + } + + if (current !== null) { + const prevProps = current.pendingProps; + const prevState: TracingMarkerState = current.memoizedState; + + // Tracing marker name hasn't changed so this means + // the previous transitions are still valid so move them over + // Not actually sure when this would update without the name changing + // but this check is just in case + if (prevProps.name === nextProps.name && prevState.transitions !== null) { + prevState.transitions.forEach(transition => { + transitions.add(transition); + }); + } + + pendingSuspenseBoundaries = prevState.pendingSuspenseBoundaries; + } else { + pendingSuspenseBoundaries = new Map(); + } + + workInProgress.memoizedState = { + transitions, + pendingSuspenseBoundaries, + }; + pushTracingMarkersPool(workInProgress); + const nextChildren = workInProgress.pendingProps.children; reconcileChildren(current, workInProgress, nextChildren, renderLanes); return workInProgress.child; @@ -1337,6 +1402,33 @@ function updateHostRoot(current, workInProgress, renderLanes) { } } + if (enableTransitionTracing) { + pushRootTransitionPool(root, renderLanes); + + const nextTransitions = new Set(); + const transitions = getSuspendedTransitionPool(); + if (transitions !== null) { + transitions.forEach(transition => { + nextTransitions.add(transition); + }); + } + const rootTransitions = prevState.transitions; + if (rootTransitions != null) { + rootTransitions.forEach(transition => { + nextTransitions.add(transition); + }); + } + + let pendingSuspenseBoundaries = prevState.pendingSuspenseBoundaries; + if (pendingSuspenseBoundaries == null) { + pendingSuspenseBoundaries = new Map(); + } + // probably have to actually copy this + workInProgress.memoizedState.transitions = nextTransitions; + workInProgress.memoizedState.pendingSuspenseBoundaries = pendingSuspenseBoundaries; + pushRootTracingMarkersPool(workInProgress); + } + // Caution: React DevTools currently depends on this property // being called "element". const nextChildren = nextState.element; @@ -1907,6 +1999,7 @@ function mountSuspenseOffscreenState(renderLanes: Lanes): OffscreenState { return { baseLanes: renderLanes, cachePool: getSuspendedCachePool(), + transitions: getSuspendedTransitionPool(), }; } @@ -1938,9 +2031,27 @@ function updateSuspenseOffscreenState( cachePool = getSuspendedCachePool(); } } + + let transitions = new Set(); + if (enableTransitionTracing) { + const prevTransitions = prevOffscreenState.transitions; + const newTransitions = getSuspendedTransitionPool(); + if (prevTransitions !== null) { + prevTransitions.forEach(transition => { + transitions.add(transition); + }); + } + if (newTransitions !== null) { + newTransitions.forEach(transition => { + transitions.add(transition); + }); + } + } + return { baseLanes: mergeLanes(prevOffscreenState.baseLanes, renderLanes), cachePool, + transitions, }; } @@ -2085,6 +2196,9 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) { primaryChildFragment.memoizedState = mountSuspenseOffscreenState( renderLanes, ); + if (enableTransitionTracing) { + primaryChildFragment.updateQueue = getSuspendedTracingMarkersPool(); + } workInProgress.memoizedState = SUSPENDED_MARKER; return fallbackFragment; } else if (typeof nextProps.unstable_expectedLoadTime === 'number') { @@ -2101,6 +2215,9 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) { primaryChildFragment.memoizedState = mountSuspenseOffscreenState( renderLanes, ); + if (enableTransitionTracing) { + primaryChildFragment.updateQueue = getSuspendedTracingMarkersPool(); + } workInProgress.memoizedState = SUSPENDED_MARKER; // Since nothing actually suspended, there will nothing to ping this to @@ -2175,6 +2292,9 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) { primaryChildFragment.memoizedState = mountSuspenseOffscreenState( renderLanes, ); + if (enableTransitionTracing) { + primaryChildFragment.updateQueue = getSuspendedTracingMarkersPool(); + } workInProgress.memoizedState = SUSPENDED_MARKER; return fallbackChildFragment; } @@ -2198,6 +2318,9 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) { prevOffscreenState === null ? mountSuspenseOffscreenState(renderLanes) : updateSuspenseOffscreenState(prevOffscreenState, renderLanes); + if (enableTransitionTracing) { + primaryChildFragment.updateQueue = getSuspendedTracingMarkersPool(); + } primaryChildFragment.childLanes = getRemainingWorkInPrimaryTree( current, renderLanes, @@ -2213,6 +2336,9 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) { renderLanes, ); workInProgress.memoizedState = null; + if (enableTransitionTracing) { + primaryChildFragment.updateQueue = getSuspendedTracingMarkersPool(); + } return primaryChildFragment; } } else { @@ -2235,6 +2361,9 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) { prevOffscreenState === null ? mountSuspenseOffscreenState(renderLanes) : updateSuspenseOffscreenState(prevOffscreenState, renderLanes); + if (enableTransitionTracing) { + primaryChildFragment.updateQueue = getSuspendedTracingMarkersPool(); + } primaryChildFragment.childLanes = getRemainingWorkInPrimaryTree( current, renderLanes, @@ -2254,6 +2383,9 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) { renderLanes, ); workInProgress.memoizedState = null; + if (enableTransitionTracing) { + primaryChildFragment.updateQueue = getSuspendedTracingMarkersPool(); + } return primaryChildFragment; } } @@ -2266,9 +2398,17 @@ function mountSuspensePrimaryChildren( renderLanes, ) { const mode = workInProgress.mode; + const props = workInProgress.memoizedProps; + let name = null; + if (props !== null) { + name = props.name; + } + const primaryChildProps: OffscreenProps = { mode: 'visible', children: primaryChildren, + name, + id: generateNewSuspenseOffscreenID(), }; const primaryChildFragment = mountWorkInProgressOffscreenFiber( primaryChildProps, @@ -2288,10 +2428,16 @@ function mountSuspenseFallbackChildren( ) { const mode = workInProgress.mode; const progressedPrimaryFragment: Fiber | null = workInProgress.child; - + const props = workInProgress.memoizedProps; + let name = null; + if (props !== null) { + name = props.name; + } const primaryChildProps: OffscreenProps = { mode: 'hidden', children: primaryChildren, + name, + id: generateNewSuspenseOffscreenID(), }; let primaryChildFragment; @@ -2369,15 +2515,22 @@ function updateSuspensePrimaryChildren( primaryChildren, renderLanes, ) { + const name = workInProgress.pendingProps.name; const currentPrimaryChildFragment: Fiber = (current.child: any); const currentFallbackChildFragment: Fiber | null = currentPrimaryChildFragment.sibling; + const props = + currentPrimaryChildFragment.memoizedProps !== null + ? currentPrimaryChildFragment.memoizedProps + : currentPrimaryChildFragment.pendingProps; const primaryChildFragment = updateWorkInProgressOffscreenFiber( currentPrimaryChildFragment, { mode: 'visible', children: primaryChildren, + name, + id: props.id, }, ); if ((workInProgress.mode & ConcurrentMode) === NoMode) { @@ -2407,14 +2560,21 @@ function updateSuspenseFallbackChildren( fallbackChildren, renderLanes, ) { + const name = workInProgress.pendingProps.name; const mode = workInProgress.mode; const currentPrimaryChildFragment: Fiber = (current.child: any); const currentFallbackChildFragment: Fiber | null = currentPrimaryChildFragment.sibling; + const props = + currentPrimaryChildFragment.memoizedProps !== null + ? currentPrimaryChildFragment.memoizedProps + : currentPrimaryChildFragment.pendingProps; const primaryChildProps: OffscreenProps = { mode: 'hidden', children: primaryChildren, + name, + id: props.id, }; let primaryChildFragment; @@ -2561,10 +2721,13 @@ function mountSuspenseFallbackAfterRetryWithoutHydrating( fallbackChildren, renderLanes, ) { + const name = workInProgress.pendingProps.name; const fiberMode = workInProgress.mode; const primaryChildProps: OffscreenProps = { mode: 'visible', children: primaryChildren, + name, + id: generateNewSuspenseOffscreenID(), }; const primaryChildFragment = mountWorkInProgressOffscreenFiber( primaryChildProps, @@ -3469,12 +3632,40 @@ function attemptEarlyBailoutIfNoScheduledUpdate( switch (workInProgress.tag) { case HostRoot: pushHostRootContext(workInProgress); + const root: FiberRoot = workInProgress.stateNode; if (enableCache) { - const root: FiberRoot = workInProgress.stateNode; const cache: Cache = current.memoizedState.cache; pushCacheProvider(workInProgress, cache); pushRootCachePool(root); } + if (enableTransitionTracing) { + const prevState = current.memoizedState; + pushRootTransitionPool(root, renderLanes); + + const nextTransitions = new Set(); + const transitions = getSuspendedTransitionPool(); + if (transitions !== null) { + transitions.forEach(transition => { + nextTransitions.add(transition); + }); + } + const rootTransitions = prevState.transitions; + if (rootTransitions != null) { + rootTransitions.forEach(transition => { + nextTransitions.add(transition); + }); + } + + let pendingSuspenseBoundaries = prevState.pendingSuspenseBoundaries; + if (pendingSuspenseBoundaries == null) { + pendingSuspenseBoundaries = new Map(); + } + // probably have to actually copy this + workInProgress.memoizedState.transitions = nextTransitions; + workInProgress.memoizedState.pendingSuspenseBoundaries = pendingSuspenseBoundaries; + + pushRootTracingMarkersPool(workInProgress); + } resetHydrationState(); break; case HostComponent: @@ -3663,6 +3854,12 @@ function attemptEarlyBailoutIfNoScheduledUpdate( } break; } + case TracingMarkerComponent: { + if (enableTransitionTracing) { + pushTracingMarkersPool(workInProgress); + } + break; + } } return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes); } diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.new.js b/packages/react-reconciler/src/ReactFiberCommitWork.new.js index 5ff6830096687..cfc51499bf04d 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.new.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.new.js @@ -25,6 +25,10 @@ import type {Wakeable} from 'shared/ReactTypes'; import type {OffscreenState} from './ReactFiberOffscreenComponent'; import type {HookFlags} from './ReactHookEffectTags'; import type {Cache} from './ReactFiberCacheComponent.new'; +import type { + Transitions, + TracingMarkerInfo, +} from './ReactFiberTracingMarkerComponent.new'; import { enableCreateEventHandleAPI, @@ -40,6 +44,7 @@ import { enableSuspenseLayoutEffectSemantics, enableUpdaterTracking, enableCache, + enableTransitionTracing, } from 'shared/ReactFeatureFlags'; import { FunctionComponent, @@ -60,6 +65,7 @@ import { OffscreenComponent, LegacyHiddenComponent, CacheComponent, + TracingMarkerComponent, } from './ReactWorkTags'; import {detachDeletedInstance} from './ReactFiberHostConfig'; import { @@ -79,6 +85,7 @@ import { LayoutMask, PassiveMask, Visibility, + SuspenseToggle, } from './ReactFiberFlags'; import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber'; import { @@ -132,6 +139,7 @@ import { markCommitTimeOfFallback, enqueuePendingPassiveProfilerEffect, restorePendingUpdaters, + addCallbackToPendingTransitionCallbacks, } from './ReactFiberWorkLoop.new'; import { NoFlags as NoHookEffect, @@ -156,6 +164,17 @@ import { onCommitUnmount, } from './ReactFiberDevToolsHook.new'; import {releaseCache, retainCache} from './ReactFiberCacheComponent.new'; +import { + MarkerProgress, + MarkerComplete, + TransitionStart, + TransitionProgress, + TransitionComplete, +} from './ReactFiberTracingMarkerComponent.new'; +import { + getTransitionsForLanes, + clearTransitionsForLanes, +} from './ReactFiberLane.new'; let didWarnAboutUndefinedSnapshotBeforeUpdate: Set | null = null; if (__DEV__) { @@ -984,7 +1003,10 @@ function commitLayoutEffectOnFiber( case ScopeComponent: case OffscreenComponent: case LegacyHiddenComponent: + case TracingMarkerComponent: { break; + } + default: throw new Error( 'This unit of work tag should not have side-effects. This error is ' + @@ -1046,6 +1068,55 @@ function reappearLayoutEffectsOnFiber(node: Fiber) { } } +function addOrRemoveBoundariesOnPendingBoundariesMap( + finishedWork: Fiber, + name: string | null, + id: number, +) { + let prevState: SuspenseState | null = null; + if ( + finishedWork.alternate !== null && + finishedWork.alternate.memoizedState !== null + ) { + prevState = finishedWork.alternate.memoizedState; + } + const nextState: SuspenseState | null = finishedWork.memoizedState; + + const wasHidden = prevState !== null; + const isHidden = nextState !== null; + const markers: TracingMarkerInfo | null = (finishedWork.updateQueue: any); + + if (markers !== null) { + markers.forEach(markerInfo => { + const pendingSuspenseBoundaries = markerInfo.pendingSuspenseBoundaries; + const transitions = markerInfo.transitions; + if (finishedWork.alternate === null) { + // Initial mount + if (isHidden) { + pendingSuspenseBoundaries.set(id, { + name, + }); + } + } else { + if (wasHidden && !isHidden) { + // The suspense boundary went from hidden to visible. Remove + // the boundary from the pending suspense boundaries set + // if it's there + if (pendingSuspenseBoundaries.has(id)) { + pendingSuspenseBoundaries.delete(id); + } + } else if (!wasHidden && isHidden) { + // The suspense boundaries was just hidden. Add the boundary + // to the pending boundary set if it's there + pendingSuspenseBoundaries.set(id, { + name, + }); + } + } + }); + } +} + function hideOrUnhideAllChildren(finishedWork, isHidden) { // Only hide or unhide the top-most host nodes. let hostSubtreeRoot = null; @@ -2137,13 +2208,13 @@ export function commitMutationEffects( inProgressRoot = root; nextEffect = firstChild; - commitMutationEffects_begin(root); + commitMutationEffects_begin(root, committedLanes); inProgressLanes = null; inProgressRoot = null; } -function commitMutationEffects_begin(root: FiberRoot) { +function commitMutationEffects_begin(root: FiberRoot, lanes: Lanes) { while (nextEffect !== null) { const fiber = nextEffect; @@ -2166,17 +2237,17 @@ function commitMutationEffects_begin(root: FiberRoot) { ensureCorrectReturnPointer(child, fiber); nextEffect = child; } else { - commitMutationEffects_complete(root); + commitMutationEffects_complete(root, lanes); } } } -function commitMutationEffects_complete(root: FiberRoot) { +function commitMutationEffects_complete(root: FiberRoot, lanes: Lanes) { while (nextEffect !== null) { const fiber = nextEffect; setCurrentDebugFiberInDEV(fiber); try { - commitMutationEffectsOnFiber(fiber, root); + commitMutationEffectsOnFiber(fiber, root, lanes); } catch (error) { reportUncaughtErrorInDEV(error); captureCommitPhaseError(fiber, fiber.return, error); @@ -2194,13 +2265,125 @@ function commitMutationEffects_complete(root: FiberRoot) { } } -function commitMutationEffectsOnFiber(finishedWork: Fiber, root: FiberRoot) { +function commitMutationEffectsOnFiber( + finishedWork: Fiber, + root: FiberRoot, + lanes: Lanes, +) { // TODO: The factoring of this phase could probably be improved. Consider // switching on the type of work before checking the flags. That's what // we do in all the other phases. I think this one is only different // because of the shared reconciliation logic below. const flags = finishedWork.flags; + if (enableTransitionTracing) { + if (flags & SuspenseToggle) { + switch (finishedWork.tag) { + case HostRoot: { + const currentTransitions = getTransitionsForLanes(root, lanes); + if (currentTransitions != null) { + currentTransitions.forEach(transition => { + addCallbackToPendingTransitionCallbacks({ + type: TransitionStart, + transitionName: transition.name, + startTime: transition.startTime, + }); + }); + } + clearTransitionsForLanes(root, lanes); + + const state = finishedWork.memoizedState; + const pendingSuspenseBoundaries = state.pendingSuspenseBoundaries; + const transitions = state.transitions; + if (transitions != null) { + transitions.forEach(transition => { + if (pendingSuspenseBoundaries.size === 0) { + addCallbackToPendingTransitionCallbacks({ + type: TransitionComplete, + transitionName: transition.name, + startTime: transition.startTime, + }); + } + + addCallbackToPendingTransitionCallbacks({ + type: TransitionProgress, + transitionName: transition.name, + startTime: transition.startTime, + pendingBoundaries: Array.from( + pendingSuspenseBoundaries.values(), + ), + }); + }); + + if (pendingSuspenseBoundaries.size === 0) { + state.transitions.clear(); + } + } + } + case OffscreenComponent: { + if (enableTransitionTracing) { + const isFallback = finishedWork.memoizedState; + let props; + if (isFallback) { + props = finishedWork.pendingProps; + } else { + props = finishedWork.memoizedProps; + } + + if (props !== null) { + if (typeof props.id !== 'number') { + console.error('wtf props id is wrong'); + } + + addOrRemoveBoundariesOnPendingBoundariesMap( + finishedWork, + props.name || null, + props.id, + ); + } + } + break; + } + case TracingMarkerComponent: { + if (enableTransitionTracing) { + const props = finishedWork.memoizedProps; + const state = finishedWork.memoizedState; + const pendingSuspenseBoundaries = state.pendingSuspenseBoundaries; + const transitions = state.transitions; + transitions.forEach(transition => { + if (pendingSuspenseBoundaries.size === 0) { + // This interaction is complete because there's no suspense boundaries + // so resolve this transition + // TODO: only do this if tehre is a marker complete callback + addCallbackToPendingTransitionCallbacks({ + type: MarkerComplete, + transitionName: transition.name, + startTime: transition.startTime, + markerName: props.name, + }); + } + // always call onMarkerProgress + addCallbackToPendingTransitionCallbacks({ + type: MarkerProgress, + transitionName: transition.name, + startTime: transition.startTime, + markerName: props.name, + pendingBoundaries: Array.from( + pendingSuspenseBoundaries.values(), + ), + }); + }); + + if (pendingSuspenseBoundaries.size === 0) { + state.transitions.clear(); + } + } + break; + } + } + } + } + if (flags & ContentReset) { commitResetTextContent(finishedWork); } diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.new.js b/packages/react-reconciler/src/ReactFiberCompleteWork.new.js index 39f724c76df92..976c1458d4064 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.new.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.new.js @@ -33,6 +33,7 @@ import { enableClientRenderFallbackOnHydrationMismatch, enableSuspenseAvoidThisFallback, } from 'shared/ReactFeatureFlags'; +import type {Transitions} from './ReactFiberTracingMarkerComponent.new'; import {resetWorkInProgressVersions as resetMutableSourceWorkInProgressVersions} from './ReactMutableSource.new'; @@ -62,6 +63,7 @@ import { OffscreenComponent, LegacyHiddenComponent, CacheComponent, + TracingMarkerComponent, } from './ReactWorkTags'; import {NoMode, ConcurrentMode, ProfileMode} from './ReactTypeOfMode'; import { @@ -80,6 +82,7 @@ import { Incomplete, ShouldCapture, ForceClientRender, + SuspenseToggle, } from './ReactFiberFlags'; import { @@ -141,6 +144,7 @@ import { enableCache, enableSuspenseLayoutEffectSemantics, enablePersistentOffscreenHostContainer, + enableTransitionTracing, } from 'shared/ReactFeatureFlags'; import { renderDidSuspend, @@ -166,6 +170,12 @@ import { popCachePool, } from './ReactFiberCacheComponent.new'; import {popTreeContext} from './ReactFiberTreeContext.new'; +import { + popRootTransitionPool, + popRootTracingMarkersPool, + popTransitionPool, + popTracingMarkersPool, +} from './ReactFiberTracingMarkerComponent.new'; function markUpdate(workInProgress: Fiber) { // Tag the fiber with an update effect. This turns a Placement into @@ -900,6 +910,18 @@ function completeWork( } updateHostContainer(current, workInProgress); bubbleProperties(workInProgress); + + if (enableTransitionTracing) { + const transitions = popRootTransitionPool(); + if ( + transitions !== null || + (workInProgress.subtreeFlags & SuspenseToggle) !== NoFlags + ) { + workInProgress.flags |= SuspenseToggle; + } + popRootTracingMarkersPool(); + } + return null; } case HostComponent: { @@ -1154,6 +1176,16 @@ function completeWork( } } + if (enableTransitionTracing) { + if ( + (current === null && nextDidTimeout) || + nextDidTimeout !== prevDidTimeout + ) { + const offscreenFiber: Fiber = (workInProgress.child: any); + offscreenFiber.flags |= SuspenseToggle; + } + } + // If the suspended state of the boundary changes, we need to schedule // an effect to toggle the subtree's visibility. When we switch from // fallback -> primary, the inner Offscreen fiber schedules this effect @@ -1550,12 +1582,33 @@ function completeWork( // Run passive effects to retain/release the cache. workInProgress.flags |= Passive; } - const spawnedCachePool: SpawnedCachePool | null = (workInProgress.updateQueue: any); - if (spawnedCachePool !== null) { + let prevState: OffscreenState | null = null; + if ( + workInProgress.alternate !== null && + workInProgress.alternate.memoizedState !== null + ) { + prevState = workInProgress.alternate.memoizedState; + } + if (prevState !== null && prevState.cachePool !== null) { popCachePool(workInProgress); } } + if (enableTransitionTracing) { + let prevTransitions: Transitions | null = null; + if ( + workInProgress.alternate !== null && + workInProgress.alternate.memoizedState !== null && + workInProgress.alternate.memoizedState.transitions !== null + ) { + prevTransitions = workInProgress.alternate.memoizedState.transitions; + } + + if (prevTransitions !== null) { + popTransitionPool(workInProgress); + } + } + return null; } case CacheComponent: { @@ -1574,6 +1627,20 @@ function completeWork( return null; } } + case TracingMarkerComponent: { + if (enableTransitionTracing) { + popTracingMarkersPool(workInProgress); + + // Bubble subtree flags before so we can set the flag property + bubbleProperties(workInProgress); + + if ((workInProgress.subtreeFlags & SuspenseToggle) !== NoFlags) { + workInProgress.flags |= SuspenseToggle; + } + + return null; + } + } } throw new Error( diff --git a/packages/react-reconciler/src/ReactFiberFlags.js b/packages/react-reconciler/src/ReactFiberFlags.js index 805c4bed918e9..44c5d8bc56344 100644 --- a/packages/react-reconciler/src/ReactFiberFlags.js +++ b/packages/react-reconciler/src/ReactFiberFlags.js @@ -12,55 +12,57 @@ import {enableCreateEventHandleAPI} from 'shared/ReactFeatureFlags'; export type Flags = number; // Don't change these two values. They're used by React Dev Tools. -export const NoFlags = /* */ 0b00000000000000000000000000; -export const PerformedWork = /* */ 0b00000000000000000000000001; +export const NoFlags = /* */ 0b000000000000000000000000000; +export const PerformedWork = /* */ 0b000000000000000000000000001; // You can change the rest (and add more). -export const Placement = /* */ 0b00000000000000000000000010; -export const Update = /* */ 0b00000000000000000000000100; +export const Placement = /* */ 0b000000000000000000000000010; +export const Update = /* */ 0b000000000000000000000000100; export const PlacementAndUpdate = /* */ Placement | Update; -export const Deletion = /* */ 0b00000000000000000000001000; -export const ChildDeletion = /* */ 0b00000000000000000000010000; -export const ContentReset = /* */ 0b00000000000000000000100000; -export const Callback = /* */ 0b00000000000000000001000000; -export const DidCapture = /* */ 0b00000000000000000010000000; -export const ForceClientRender = /* */ 0b00000000000000000100000000; -export const Ref = /* */ 0b00000000000000001000000000; -export const Snapshot = /* */ 0b00000000000000010000000000; -export const Passive = /* */ 0b00000000000000100000000000; -export const Hydrating = /* */ 0b00000000000001000000000000; +export const Deletion = /* */ 0b000000000000000000000001000; +export const ChildDeletion = /* */ 0b000000000000000000000010000; +export const ContentReset = /* */ 0b000000000000000000000100000; +export const Callback = /* */ 0b000000000000000000001000000; +export const DidCapture = /* */ 0b000000000000000000010000000; +export const ForceClientRender = /* */ 0b000000000000000000100000000; +export const Ref = /* */ 0b000000000000000001000000000; +export const Snapshot = /* */ 0b000000000000000010000000000; +export const Passive = /* */ 0b000000000000000100000000000; +export const Hydrating = /* */ 0b000000000000001000000000000; export const HydratingAndUpdate = /* */ Hydrating | Update; -export const Visibility = /* */ 0b00000000000010000000000000; -export const StoreConsistency = /* */ 0b00000000000100000000000000; +export const Visibility = /* */ 0b000000000000010000000000000; +export const StoreConsistency = /* */ 0b000000000000100000000000000; export const LifecycleEffectMask = Passive | Update | Callback | Ref | Snapshot | StoreConsistency; // Union of all commit flags (flags with the lifetime of a particular commit) -export const HostEffectMask = /* */ 0b00000000000111111111111111; +export const HostEffectMask = /* */ 0b000000000000111111111111111; // These are not really side effects, but we still reuse this field. -export const Incomplete = /* */ 0b00000000001000000000000000; -export const ShouldCapture = /* */ 0b00000000010000000000000000; -export const ForceUpdateForLegacySuspense = /* */ 0b00000000100000000000000000; -export const DidPropagateContext = /* */ 0b00000001000000000000000000; -export const NeedsPropagation = /* */ 0b00000010000000000000000000; -export const Forked = /* */ 0b00000100000000000000000000; +export const Incomplete = /* */ 0b000000000001000000000000000; +export const ShouldCapture = /* */ 0b000000000010000000000000000; +export const ForceUpdateForLegacySuspense = /* */ 0b000000000100000000000000000; +export const DidPropagateContext = /* */ 0b000000001000000000000000000; +export const NeedsPropagation = /* */ 0b000000010000000000000000000; +export const Forked = /* */ 0b000000100000000000000000000; // Static tags describe aspects of a fiber that are not specific to a render, // e.g. a fiber uses a passive effect (even if there are no updates on this particular render). // This enables us to defer more work in the unmount case, // since we can defer traversing the tree during layout to look for Passive effects, // and instead rely on the static flag as a signal that there may be cleanup work. -export const RefStatic = /* */ 0b00001000000000000000000000; -export const LayoutStatic = /* */ 0b00010000000000000000000000; -export const PassiveStatic = /* */ 0b00100000000000000000000000; +export const RefStatic = /* */ 0b000001000000000000000000000; +export const LayoutStatic = /* */ 0b000010000000000000000000000; +export const PassiveStatic = /* */ 0b000100000000000000000000000; // These flags allow us to traverse to fibers that have effects on mount // without traversing the entire tree after every commit for // double invoking -export const MountLayoutDev = /* */ 0b01000000000000000000000000; -export const MountPassiveDev = /* */ 0b10000000000000000000000000; +export const MountLayoutDev = /* */ 0b001000000000000000000000000; +export const MountPassiveDev = /* */ 0b010000000000000000000000000; + +export const SuspenseToggle = /* */ 0b100000000000000000000000000; // Groups of flags that are used in the commit phase to skip over trees that // don't contain effects, by checking subtreeFlags. @@ -85,7 +87,8 @@ export const MutationMask = ContentReset | Ref | Hydrating | - Visibility; + Visibility | + SuspenseToggle; export const LayoutMask = Update | Callback | Ref | Visibility; // TODO: Split into PassiveMountMask and PassiveUnmountMask diff --git a/packages/react-reconciler/src/ReactFiberHooks.new.js b/packages/react-reconciler/src/ReactFiberHooks.new.js index 7747a25c858ed..daee891694ac5 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.new.js +++ b/packages/react-reconciler/src/ReactFiberHooks.new.js @@ -12,6 +12,7 @@ import type { MutableSourceGetSnapshotFn, MutableSourceSubscribeFn, ReactContext, + StartTransitionOptions, } from 'shared/ReactTypes'; import type {Fiber, Dispatcher, HookType} from './ReactInternalTypes'; import type {Lanes, Lane} from './ReactFiberLane.new'; @@ -31,6 +32,7 @@ import { enableLazyContextPropagation, enableSuspenseLayoutEffectSemantics, enableUseMutableSource, + enableTransitionTracing, } from 'shared/ReactFeatureFlags'; import { @@ -111,6 +113,7 @@ import { import {pushInterleavedQueue} from './ReactFiberInterleavedUpdates.new'; import {warnOnSubscriptionInsideStartTransition} from 'shared/ReactFeatureFlags'; import {getTreeId} from './ReactFiberTreeContext.new'; +import {getCurrentEventStartTime} from './ReactFiberHostConfig'; const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals; @@ -1929,10 +1932,21 @@ function mountDeferredValue(value: T): T { mountEffect(() => { const prevTransition = ReactCurrentBatchConfig.transition; ReactCurrentBatchConfig.transition = 1; + + let prevTransitionInfo = null; + if (enableTransitionTracing) { + prevTransitionInfo = ReactCurrentBatchConfig.transitionInfo; + ReactCurrentBatchConfig.transitionInfo = null; + } + try { setValue(value); } finally { ReactCurrentBatchConfig.transition = prevTransition; + + if (enableTransitionTracing) { + ReactCurrentBatchConfig.transitionInfo = prevTransitionInfo; + } } }, [value]); return prevValue; @@ -1943,10 +1957,21 @@ function updateDeferredValue(value: T): T { updateEffect(() => { const prevTransition = ReactCurrentBatchConfig.transition; ReactCurrentBatchConfig.transition = 1; + + let prevTransitionInfo = null; + if (enableTransitionTracing) { + prevTransitionInfo = ReactCurrentBatchConfig.transitionInfo; + ReactCurrentBatchConfig.transitionInfo = null; + } + try { setValue(value); } finally { ReactCurrentBatchConfig.transition = prevTransition; + + if (enableTransitionTracing) { + ReactCurrentBatchConfig.transitionInfo = prevTransitionInfo; + } } }, [value]); return prevValue; @@ -1957,16 +1982,27 @@ function rerenderDeferredValue(value: T): T { updateEffect(() => { const prevTransition = ReactCurrentBatchConfig.transition; ReactCurrentBatchConfig.transition = 1; + + let prevTransitionInfo = null; + if (enableTransitionTracing) { + prevTransitionInfo = ReactCurrentBatchConfig.transitionInfo; + ReactCurrentBatchConfig.transitionInfo = null; + } + try { setValue(value); } finally { ReactCurrentBatchConfig.transition = prevTransition; + + if (enableTransitionTracing) { + ReactCurrentBatchConfig.transitionInfo = prevTransitionInfo; + } } }, [value]); return prevValue; } -function startTransition(setPending, callback) { +function startTransition(setPending, callback, options) { const previousPriority = getCurrentUpdatePriority(); setCurrentUpdatePriority( higherEventPriority(previousPriority, ContinuousEventPriority), @@ -1976,12 +2012,31 @@ function startTransition(setPending, callback) { const prevTransition = ReactCurrentBatchConfig.transition; ReactCurrentBatchConfig.transition = 1; + + let prevTransitionInfo = null; + if (enableTransitionTracing) { + prevTransitionInfo = ReactCurrentBatchConfig.transitionInfo; + if (options !== undefined && options.name !== undefined) { + ReactCurrentBatchConfig.transitionInfo = { + name: options.name, + startTime: getCurrentEventStartTime(), + }; + } else { + ReactCurrentBatchConfig.transitionInfo = null; + } + } + try { setPending(false); callback(); } finally { setCurrentUpdatePriority(previousPriority); ReactCurrentBatchConfig.transition = prevTransition; + + if (enableTransitionTracing) { + ReactCurrentBatchConfig.transitionInfo = prevTransitionInfo; + } + if (__DEV__) { if ( prevTransition !== 1 && @@ -2002,7 +2057,10 @@ function startTransition(setPending, callback) { } } -function mountTransition(): [boolean, (() => void) => void] { +function mountTransition(): [ + boolean, + (callback: () => void, options?: StartTransitionOptions) => void, +] { const [isPending, setPending] = mountState(false); // The `start` method never changes. const start = startTransition.bind(null, setPending); @@ -2011,14 +2069,20 @@ function mountTransition(): [boolean, (() => void) => void] { return [isPending, start]; } -function updateTransition(): [boolean, (() => void) => void] { +function updateTransition(): [ + boolean, + (callback: () => void, options?: StartTransitionOptions) => void, +] { const [isPending] = updateState(false); const hook = updateWorkInProgressHook(); const start = hook.memoizedState; return [isPending, start]; } -function rerenderTransition(): [boolean, (() => void) => void] { +function rerenderTransition(): [ + boolean, + (callback: () => void, options?: StartTransitionOptions) => void, +] { const [isPending] = rerenderState(false); const hook = updateWorkInProgressHook(); const start = hook.memoizedState; diff --git a/packages/react-reconciler/src/ReactFiberLane.new.js b/packages/react-reconciler/src/ReactFiberLane.new.js index b9073b0770ba8..b4e55c906e3eb 100644 --- a/packages/react-reconciler/src/ReactFiberLane.new.js +++ b/packages/react-reconciler/src/ReactFiberLane.new.js @@ -8,6 +8,10 @@ */ import type {FiberRoot} from './ReactInternalTypes'; +import type { + Transition, + Transitions, +} from './ReactFiberTracingMarkerComponent.new'; // TODO: Ideally these types would be opaque but that doesn't work well with // our reconciler fork infra, since these leak into non-reconciler packages. @@ -20,6 +24,7 @@ import { enableSchedulingProfiler, enableUpdaterTracking, allowConcurrentByDefault, + enableTransitionTracing, } from 'shared/ReactFeatureFlags'; import {isDevToolsPresent} from './ReactFiberDevToolsHook.new'; import {ConcurrentUpdatesByDefaultMode, NoMode} from './ReactTypeOfMode'; @@ -792,3 +797,73 @@ export function movePendingFibersToMemoized(root: FiberRoot, lanes: Lanes) { lanes &= ~lane; } } + +export function addTransitionToLanesMap( + root: FiberRoot, + transition: Transition, + lane: Lane, +) { + if (enableTransitionTracing) { + const transitionLanesMap = root.transitionLanes; + const index = laneToIndex(lane); + const transitions = transitionLanesMap[index]; + if (transitions !== null) { + transitions.add(transition); + } else { + console.error( + `React Bug: transition lanes accessed out of bounds index: ${index}`, + ); + } + } +} + +export function getTransitionsForLanes( + root: FiberRoot, + lanes: Lane | Lanes, +): Transitions | null { + if (!enableTransitionTracing) { + return null; + } + + const transitionsForLanes = new Set(); + while (lanes > 0) { + const index = laneToIndex(lanes); + const lane = 1 << index; + const transitions = root.transitionLanes[index]; + if (transitions !== null) { + transitions.forEach(transition => { + transitionsForLanes.add(transition); + }); + } else { + console.error( + `React Bug: transition lanes accessed out of bounds index: ${index}`, + ); + } + + lanes &= ~lane; + } + + return transitionsForLanes; +} + +export function clearTransitionsForLanes(root: FiberRoot, lanes: Lane | Lanes) { + if (!enableTransitionTracing) { + return; + } + + while (lanes > 0) { + const index = laneToIndex(lanes); + const lane = 1 << index; + + const transitions = root.transitionLanes[index]; + if (transitions !== null) { + transitions.clear(); + } else { + console.error( + `React Bug: transition lanes accessed out of bounds index: ${index}`, + ); + } + + lanes &= ~lane; + } +} diff --git a/packages/react-reconciler/src/ReactFiberOffscreenComponent.js b/packages/react-reconciler/src/ReactFiberOffscreenComponent.js index 87e1eaa244540..72d252cf17970 100644 --- a/packages/react-reconciler/src/ReactFiberOffscreenComponent.js +++ b/packages/react-reconciler/src/ReactFiberOffscreenComponent.js @@ -10,6 +10,7 @@ import type {ReactNodeList, OffscreenMode} from 'shared/ReactTypes'; import type {Lanes} from './ReactFiberLane.old'; import type {SpawnedCachePool} from './ReactFiberCacheComponent.new'; +import type {Transitions} from './ReactFiberTracingMarkerComponent.new'; export type OffscreenProps = {| // TODO: Pick an API before exposing the Offscreen type. I've chosen an enum @@ -20,6 +21,8 @@ export type OffscreenProps = {| // called "Offscreen." Possible alt: ? mode?: OffscreenMode | null | void, children?: ReactNodeList, + name?: string | null, + id?: number, |}; // We use the existence of the state object as an indicator that the component @@ -30,4 +33,5 @@ export type OffscreenState = {| // order to unhide the component. baseLanes: Lanes, cachePool: SpawnedCachePool | null, + transitions: Transitions | null, |}; diff --git a/packages/react-reconciler/src/ReactFiberRoot.new.js b/packages/react-reconciler/src/ReactFiberRoot.new.js index 3a4546008ad50..d61eb34339772 100644 --- a/packages/react-reconciler/src/ReactFiberRoot.new.js +++ b/packages/react-reconciler/src/ReactFiberRoot.new.js @@ -13,6 +13,11 @@ import type { TransitionTracingCallbacks, } from './ReactInternalTypes'; import type {RootTag} from './ReactRootTags'; +import type {Cache} from './ReactFiberCacheComponent.new'; +import type { + Transitions, + PendingSuspenseBoundaries, +} from './ReactFiberTracingMarkerComponent.new'; import {noTimeout, supportsHydration} from './ReactFiberHostConfig'; import {createHostRootFiber} from './ReactFiber.new'; @@ -35,6 +40,13 @@ import {initializeUpdateQueue} from './ReactUpdateQueue.new'; import {LegacyRoot, ConcurrentRoot} from './ReactRootTags'; import {createCache, retainCache} from './ReactFiberCacheComponent.new'; +export type RootState = { + element: any, + cache?: Cache | null, + pendingSuspenseBoundaries?: PendingSuspenseBoundaries | null, + transitions?: Transitions | null, +}; + function FiberRootNode( containerInfo, tag, @@ -85,6 +97,10 @@ function FiberRootNode( if (enableTransitionTracing) { this.transitionCallbacks = null; + const transitionLanesMap = (this.transitionLanes = []); + for (let i = 0; i < TotalLanes; i++) { + transitionLanesMap.push(new Set()); + } } if (enableProfilerTimer && enableProfilerCommitHooks) { @@ -165,13 +181,13 @@ export function createFiberRoot( // retained separately. root.pooledCache = initialCache; retainCache(initialCache); - const initialState = { + const initialState: RootState = { element: null, cache: initialCache, }; uninitializedFiber.memoizedState = initialState; } else { - const initialState = { + const initialState: RootState = { element: null, }; uninitializedFiber.memoizedState = initialState; diff --git a/packages/react-reconciler/src/ReactFiberSuspenseComponent.new.js b/packages/react-reconciler/src/ReactFiberSuspenseComponent.new.js index 54e37a9004d7f..4f4e8c39b271e 100644 --- a/packages/react-reconciler/src/ReactFiberSuspenseComponent.new.js +++ b/packages/react-reconciler/src/ReactFiberSuspenseComponent.new.js @@ -29,6 +29,7 @@ export type SuspenseProps = {| suspenseCallback?: (Set | null) => mixed, unstable_expectedLoadTime?: number, + name?: string, |}; // A null SuspenseState represents an unsuspended normal Suspense boundary. diff --git a/packages/react-reconciler/src/ReactFiberTracingMarkerComponent.new.js b/packages/react-reconciler/src/ReactFiberTracingMarkerComponent.new.js new file mode 100644 index 0000000000000..20728ea9a4512 --- /dev/null +++ b/packages/react-reconciler/src/ReactFiberTracingMarkerComponent.new.js @@ -0,0 +1,306 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 {ReactNodeList} from 'shared/ReactTypes'; +import type {Lane, Lanes} from './ReactFiberLane.new'; +import type { + Fiber, + FiberRoot, + TransitionTracingCallbacks, +} from './ReactInternalTypes'; +import type {StackCursor} from './ReactFiberStack.new'; + +import {enableTransitionTracing} from 'shared/ReactFeatureFlags'; +import {getTransitionsForLanes} from './ReactFiberLane.new'; +import {createCursor, push, pop} from './ReactFiberStack.new'; + +export type SuspenseInfo = {name: string | null}; +export type PendingSuspenseBoundaries = Map; + +export type TracingMarkerState = {| + +pendingSuspenseBoundaries: PendingSuspenseBoundaries, + +transitions: Transitions, +|}; + +export type TracingMarkerProps = {| + children?: ReactNodeList, + name: string, +|}; + +export type Transition = { + name: string, + startTime: number, +}; + +export type Transitions = Set | null; +export type TracingMarkerInfo = Array<{ + transitions: Transitions, + // Convert this from array to map because you can remove stuff here + pendingSuspenseBoundaries: PendingSuspenseBoundaries, +}>; + +export type TransitionCallbackObject = {| + type: TransitionCallback, + transitionName: string, + startTime: number, + markerName?: string, + pendingBoundaries?: Array, +|}; + +export type TransitionCallback = 0 | 1 | 2 | 3 | 4; + +export const TransitionStart = 0; +export const TransitionProgress = 1; +export const MarkerProgress = 2; +export const TransitionComplete = 3; +export const MarkerComplete = 4; + +let currentTransitions: Transitions | null = null; +const transitionStack: StackCursor = createCursor(null); + +let currentTracingMarkers: TracingMarkerInfo | null = null; +const tracingMarkersStack: StackCursor = createCursor( + null, +); + +// TODO(luna) Refactor this with cache component +// to have a joint stack +export function pushTransitionPool( + workInProgress: Fiber, + transitions: Set | null, +) { + if (!enableTransitionTracing) { + return; + } + + // This works becuase we only make the transition object + // when the transition first starts + const newTransitions = new Set(); + if (currentTransitions !== null) { + currentTransitions.forEach(transition => { + newTransitions.add(transition); + }); + } + if (transitions !== null) { + transitions.forEach(transition => { + newTransitions.add(transition); + }); + } + + push(transitionStack, currentTransitions, workInProgress); + currentTransitions = newTransitions; +} + +export function popTransitionPool(workInProgress: Fiber) { + if (!enableTransitionTracing) { + return; + } + + currentTransitions = transitionStack.current; + pop(transitionStack, workInProgress); +} + +export function pushRootTransitionPool(root: FiberRoot, lanes: Lane | Lanes) { + if (!enableTransitionTracing) { + return; + } + // Assuming that retries will always + // happen in the retry lane and there will never + // be transitions in the retry lane, so therefore + // this will always be an empty array + const rootTransitions = getTransitionsForLanes(root, lanes); + if (rootTransitions != null && rootTransitions.size > 0) { + currentTransitions = rootTransitions; + } else { + currentTransitions = null; + } + + return currentTransitions; +} + +export function popRootTransitionPool() { + if (!enableTransitionTracing) { + return; + } + const transitions = currentTransitions; + currentTransitions = null; + + return transitions; +} + +export function getSuspendedTransitionPool(): Transitions | null { + if (!enableTransitionTracing) { + return null; + } + + if (currentTransitions === null) { + return null; + } + + return currentTransitions; +} + +export function pushRootTracingMarkersPool(rootFiber: Fiber) { + if (!enableTransitionTracing) { + return; + } + + const state = rootFiber.memoizedState; + + currentTracingMarkers = [ + { + pendingSuspenseBoundaries: state.pendingSuspenseBoundaries, + transitions: state.transitions, + }, + ]; +} + +export function popRootTracingMarkersPool() { + if (!enableTransitionTracing) { + return; + } + + currentTracingMarkers = null; +} + +export function pushTracingMarkersPool(tracingMarker: Fiber) { + if (!enableTransitionTracing) { + return; + } + + let tracingMarkersArray; + if (currentTracingMarkers === null) { + tracingMarkersArray = []; + } else { + tracingMarkersArray = Array.from(currentTracingMarkers); + } + + const state = tracingMarker.memoizedState; + + const tracingMarkerData = { + pendingSuspenseBoundaries: state.pendingSuspenseBoundaries, + transitions: state.transitions, + }; + tracingMarkersArray.push(tracingMarkerData); + + push(tracingMarkersStack, currentTracingMarkers, tracingMarker); + currentTracingMarkers = tracingMarkersArray; +} + +export function popTracingMarkersPool(workInProgress: Fiber) { + if (!enableTransitionTracing) { + return; + } + + currentTracingMarkers = tracingMarkersStack.current; + pop(transitionStack, workInProgress); +} + +export function getSuspendedTracingMarkersPool(): TracingMarkerInfo | null { + if (!enableTransitionTracing) { + return null; + } + + if (currentTracingMarkers == null) { + return null; + } else { + return currentTracingMarkers; + } +} + +export function processTransitionCallbacks( + pendingTransitions: Array, + endTime: number, + callbacks: TransitionTracingCallbacks, +): void { + pendingTransitions.forEach(transition => { + switch (transition.type) { + case TransitionStart: { + if (callbacks.onTransitionStart != null) { + callbacks.onTransitionStart( + transition.transitionName, + transition.startTime, + ); + } + break; + } + case TransitionComplete: { + if (callbacks.onTransitionComplete != null) { + callbacks.onTransitionComplete( + transition.transitionName, + transition.startTime, + endTime, + ); + } + break; + } + case MarkerComplete: { + if (callbacks.onMarkerComplete != null) { + if (transition.markerName != null) { + callbacks.onMarkerComplete( + transition.transitionName, + transition.markerName, + transition.startTime, + endTime, + ); + } else { + console.error( + 'React bug: Calling onMarkerComplete transition tracing callback' + + 'but markerName is null', + ); + } + } + break; + } + case TransitionProgress: { + if (callbacks.onTransitionProgress != null) { + if (transition.pendingBoundaries != null) { + callbacks.onTransitionProgress( + transition.transitionName, + transition.startTime, + endTime, + transition.pendingBoundaries, + ); + } else { + console.error( + 'React bug: Calling onTransitionProgress transition tracing callback' + + 'but pendingBoundaries is null', + ); + } + } + + break; + } + case MarkerProgress: { + if (callbacks.onMarkerProgress != null) { + if ( + transition.markerName != null && + transition.pendingBoundaries != null + ) { + callbacks.onMarkerProgress( + transition.transitionName, + transition.markerName, + transition.startTime, + endTime, + transition.pendingBoundaries, + ); + } else { + console.error( + 'React bug: Calling onTransitionProgress transition tracing callback' + + 'but either markerName or pendingBoundaries is null: pendingBoundaries - ', + transition.pendingBoundaries, + ' markerName: ', + transition.markerName, + ); + } + } + break; + } + } + }); +} diff --git a/packages/react-reconciler/src/ReactFiberUnwindWork.new.js b/packages/react-reconciler/src/ReactFiberUnwindWork.new.js index a43c021d987e5..d675ad6bfb13b 100644 --- a/packages/react-reconciler/src/ReactFiberUnwindWork.new.js +++ b/packages/react-reconciler/src/ReactFiberUnwindWork.new.js @@ -12,6 +12,8 @@ import type {Fiber, FiberRoot} from './ReactInternalTypes'; import type {Lanes} from './ReactFiberLane.new'; import type {SuspenseState} from './ReactFiberSuspenseComponent.new'; import type {Cache, SpawnedCachePool} from './ReactFiberCacheComponent.new'; +import type {Transitions} from './ReactFiberTracingMarkerComponent.new'; +import type {OffscreenState} from './ReactFiberOffscreenComponent'; import {resetWorkInProgressVersions as resetMutableSourceWorkInProgressVersions} from './ReactMutableSource.new'; import { @@ -25,6 +27,7 @@ import { OffscreenComponent, LegacyHiddenComponent, CacheComponent, + TracingMarkerComponent, } from './ReactWorkTags'; import {DidCapture, NoFlags, ShouldCapture} from './ReactFiberFlags'; import {NoMode, ProfileMode} from './ReactTypeOfMode'; @@ -32,6 +35,7 @@ import { enableSuspenseServerRenderer, enableProfilerTimer, enableCache, + enableTransitionTracing, } from 'shared/ReactFeatureFlags'; import {popHostContainer, popHostContext} from './ReactFiberHostContext.new'; @@ -51,6 +55,12 @@ import { } from './ReactFiberCacheComponent.new'; import {transferActualDuration} from './ReactProfilerTimer.new'; import {popTreeContext} from './ReactFiberTreeContext.new'; +import { + popRootTransitionPool, + popRootTracingMarkersPool, + popTransitionPool, + popTracingMarkersPool, +} from './ReactFiberTracingMarkerComponent.new'; function unwindWork(workInProgress: Fiber, renderLanes: Lanes) { // Note: This intentionally doesn't check if we're hydrating because comparing @@ -85,6 +95,11 @@ function unwindWork(workInProgress: Fiber, renderLanes: Lanes) { const cache: Cache = workInProgress.memoizedState.cache; popCacheProvider(workInProgress, cache); } + + if (enableTransitionTracing) { + popRootTransitionPool(); + popRootTracingMarkersPool(); + } popHostContainer(workInProgress); popTopLevelLegacyContextObject(workInProgress); resetMutableSourceWorkInProgressVersions(); @@ -152,12 +167,33 @@ function unwindWork(workInProgress: Fiber, renderLanes: Lanes) { case OffscreenComponent: case LegacyHiddenComponent: popRenderLanes(workInProgress); + let prevState: OffscreenState | null = null; if (enableCache) { - const spawnedCachePool: SpawnedCachePool | null = (workInProgress.updateQueue: any); - if (spawnedCachePool !== null) { + if ( + workInProgress.alternate !== null && + workInProgress.alternate.memoizedState !== null + ) { + prevState = workInProgress.alternate.memoizedState; + } + if (prevState !== null && prevState.cachePool !== null) { popCachePool(workInProgress); } } + if (enableTransitionTracing) { + let prevTransitions: Transitions | null = null; + if ( + workInProgress.alternate !== null && + workInProgress.alternate.memoizedState !== null && + workInProgress.alternate.memoizedState.transitions !== null + ) { + prevTransitions = workInProgress.alternate.memoizedState.transitions; + } + + if (prevTransitions !== null) { + popTransitionPool(workInProgress); + } + } + return null; case CacheComponent: if (enableCache) { @@ -165,6 +201,11 @@ function unwindWork(workInProgress: Fiber, renderLanes: Lanes) { popCacheProvider(workInProgress, cache); } return null; + case TracingMarkerComponent: + if (enableTransitionTracing) { + popTracingMarkersPool(workInProgress); + } + return null; default: return null; } @@ -192,6 +233,10 @@ function unwindInterruptedWork(interruptedWork: Fiber, renderLanes: Lanes) { const cache: Cache = interruptedWork.memoizedState.cache; popCacheProvider(interruptedWork, cache); } + if (enableTransitionTracing) { + popRootTransitionPool(); + popRootTracingMarkersPool(); + } popHostContainer(interruptedWork); popTopLevelLegacyContextObject(interruptedWork); resetMutableSourceWorkInProgressVersions(); @@ -218,12 +263,32 @@ function unwindInterruptedWork(interruptedWork: Fiber, renderLanes: Lanes) { case LegacyHiddenComponent: popRenderLanes(interruptedWork); if (enableCache) { - const spawnedCachePool: SpawnedCachePool | null = (interruptedWork.updateQueue: any); - if (spawnedCachePool !== null) { + let prevState: OffscreenState | null = null; + if ( + interruptedWork.alternate !== null && + interruptedWork.alternate.memoizedState !== null + ) { + prevState = interruptedWork.alternate.memoizedState; + } + if (prevState !== null && prevState.cachePool !== null) { popCachePool(interruptedWork); } } + if (enableTransitionTracing) { + let prevTransitions: Transitions | null = null; + if ( + interruptedWork.alternate !== null && + interruptedWork.alternate.memoizedState !== null && + interruptedWork.alternate.memoizedState.transitions !== null + ) { + prevTransitions = interruptedWork.alternate.memoizedState.transitions; + } + + if (prevTransitions !== null) { + popTransitionPool(interruptedWork); + } + } break; case CacheComponent: if (enableCache) { @@ -231,6 +296,11 @@ function unwindInterruptedWork(interruptedWork: Fiber, renderLanes: Lanes) { popCacheProvider(interruptedWork, cache); } break; + case TracingMarkerComponent: + if (enableTransitionTracing) { + popTracingMarkersPool(interruptedWork); + } + break; default: break; } diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js index 42d230a4a655f..ed496818c13de 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js @@ -15,6 +15,7 @@ import type {StackCursor} from './ReactFiberStack.new'; import type {Flags} from './ReactFiberFlags'; import type {FunctionComponentUpdateQueue} from './ReactFiberHooks.new'; import type {EventPriority} from './ReactEventPriorities.new'; +import type {TransitionCallbackObject} from './ReactFiberTracingMarkerComponent.new'; import { warnAboutDeprecatedLifecycles, @@ -34,6 +35,7 @@ import { enableUpdaterTracking, warnOnSubscriptionInsideStartTransition, enableCache, + enableTransitionTracing, } from 'shared/ReactFeatureFlags'; import ReactSharedInternals from 'shared/ReactSharedInternals'; import is from 'shared/objectIs'; @@ -77,6 +79,8 @@ import { supportsMicrotasks, errorHydratingContainer, scheduleMicrotask, + getCurrentEventStartTime, + scheduleTransitionCallbacks, } from './ReactFiberHostConfig'; import { @@ -140,6 +144,7 @@ import { getHighestPriorityLane, addFiberToLanesMap, movePendingFibersToMemoized, + addTransitionToLanesMap, } from './ReactFiberLane.new'; import { DiscreteEventPriority, @@ -233,6 +238,7 @@ import { isLegacyActEnvironment, isConcurrentActEnvironment, } from './ReactFiberAct.new'; +import {processTransitionCallbacks} from './ReactFiberTracingMarkerComponent.new'; const ceil = Math.ceil; @@ -316,6 +322,25 @@ let workInProgressRootRenderTargetTime: number = Infinity; // suspense heuristics and opt out of rendering more content. const RENDER_TIMEOUT_MS = 500; +let suspenseOffscreenID: number = 0; + +export function generateNewSuspenseOffscreenID(): number { + return suspenseOffscreenID++; +} + +let currentPendingTransitionCallbacks: Array | null = null; +export function addCallbackToPendingTransitionCallbacks( + callbackObj: TransitionCallbackObject, +) { + if (enableTransitionTracing) { + if (currentPendingTransitionCallbacks === null) { + currentPendingTransitionCallbacks = []; + } + + currentPendingTransitionCallbacks.push(callbackObj); + } +} + function resetRenderTimer() { workInProgressRootRenderTargetTime = now() + RENDER_TIMEOUT_MS; } @@ -517,6 +542,17 @@ export function scheduleUpdateOnFiber( } } + if (enableTransitionTracing) { + const transition = ReactCurrentBatchConfig.transitionInfo; + if (transition !== null) { + if (transition.startTime === -1) { + transition.startTime = getCurrentEventStartTime(); + } + + addTransitionToLanesMap(root, transition, lane); + } + } + // TODO: Consolidate with `isInterleavedUpdate` check if (root === workInProgressRoot) { // Received an update to a tree that's in the middle of rendering. Mark @@ -1198,13 +1234,27 @@ export function getExecutionContext(): ExecutionContext { export function deferredUpdates(fn: () => A): A { const previousPriority = getCurrentUpdatePriority(); const prevTransition = ReactCurrentBatchConfig.transition; + let prevTransitionInfo = null; + if (enableTransitionTracing) { + prevTransitionInfo = ReactCurrentBatchConfig.transitionInfo; + } + try { ReactCurrentBatchConfig.transition = 0; + + if (enableTransitionTracing) { + ReactCurrentBatchConfig.transitionInfo = null; + } + setCurrentUpdatePriority(DefaultEventPriority); return fn(); } finally { setCurrentUpdatePriority(previousPriority); ReactCurrentBatchConfig.transition = prevTransition; + + if (enableTransitionTracing) { + ReactCurrentBatchConfig.transitionInfo = prevTransitionInfo; + } } } @@ -1237,13 +1287,24 @@ export function discreteUpdates( ): R { const previousPriority = getCurrentUpdatePriority(); const prevTransition = ReactCurrentBatchConfig.transition; + let prevTransitionInfo = null; + if (enableTransitionTracing) { + prevTransitionInfo = ReactCurrentBatchConfig.transitionInfo; + } + try { ReactCurrentBatchConfig.transition = 0; + if (enableTransitionTracing) { + ReactCurrentBatchConfig.transitionInfo = null; + } setCurrentUpdatePriority(DiscreteEventPriority); return fn(a, b, c, d); } finally { setCurrentUpdatePriority(previousPriority); ReactCurrentBatchConfig.transition = prevTransition; + if (enableTransitionTracing) { + ReactCurrentBatchConfig.transitionInfo = prevTransitionInfo; + } if (executionContext === NoContext) { resetRenderTimer(); } @@ -1272,8 +1333,16 @@ export function flushSync(fn) { const prevTransition = ReactCurrentBatchConfig.transition; const previousPriority = getCurrentUpdatePriority(); + let prevTransitionInfo = null; + if (enableTransitionTracing) { + prevTransitionInfo = ReactCurrentBatchConfig.transitionInfo; + } + try { ReactCurrentBatchConfig.transition = 0; + if (enableTransitionTracing) { + ReactCurrentBatchConfig.transitionInfo = null; + } setCurrentUpdatePriority(DiscreteEventPriority); if (fn) { return fn(); @@ -1283,6 +1352,10 @@ export function flushSync(fn) { } finally { setCurrentUpdatePriority(previousPriority); ReactCurrentBatchConfig.transition = prevTransition; + if (enableTransitionTracing) { + ReactCurrentBatchConfig.transitionInfo = prevTransitionInfo; + } + executionContext = prevExecutionContext; // Flush the immediate callbacks that were scheduled during this batch. // Note that this will happen even if batchedUpdates is higher up @@ -1307,13 +1380,24 @@ export function flushControlled(fn: () => mixed): void { executionContext |= BatchedContext; const prevTransition = ReactCurrentBatchConfig.transition; const previousPriority = getCurrentUpdatePriority(); + let prevTransitionInfo = null; + if (enableTransitionTracing) { + prevTransitionInfo = ReactCurrentBatchConfig.transitionInfo; + } try { ReactCurrentBatchConfig.transition = 0; setCurrentUpdatePriority(DiscreteEventPriority); + if (enableTransitionTracing) { + ReactCurrentBatchConfig.transitionInfo = null; + } + fn(); } finally { setCurrentUpdatePriority(previousPriority); ReactCurrentBatchConfig.transition = prevTransition; + if (enableTransitionTracing) { + ReactCurrentBatchConfig.transitionInfo = prevTransitionInfo; + } executionContext = prevExecutionContext; if (executionContext === NoContext) { @@ -1845,13 +1929,25 @@ function commitRoot(root: FiberRoot, recoverableErrors: null | Array) { // layout phases. Should be able to remove. const previousUpdateLanePriority = getCurrentUpdatePriority(); const prevTransition = ReactCurrentBatchConfig.transition; + + let prevTransitionInfo = null; + if (enableTransitionTracing) { + prevTransitionInfo = ReactCurrentBatchConfig.transitionInfo; + } + try { ReactCurrentBatchConfig.transition = 0; setCurrentUpdatePriority(DiscreteEventPriority); + if (enableTransitionTracing) { + ReactCurrentBatchConfig.transitionInfo = null; + } commitRootImpl(root, recoverableErrors, previousUpdateLanePriority); } finally { ReactCurrentBatchConfig.transition = prevTransition; setCurrentUpdatePriority(previousUpdateLanePriority); + if (enableTransitionTracing) { + ReactCurrentBatchConfig.transitionInfo = prevTransitionInfo; + } } return null; @@ -1984,6 +2080,11 @@ function commitRootImpl( ReactCurrentBatchConfig.transition = 0; const previousPriority = getCurrentUpdatePriority(); setCurrentUpdatePriority(DiscreteEventPriority); + let prevTransitionInfo = null; + if (enableTransitionTracing) { + prevTransitionInfo = ReactCurrentBatchConfig.transitionInfo; + ReactCurrentBatchConfig.transitionInfo = null; + } const prevExecutionContext = executionContext; executionContext |= CommitContext; @@ -2066,6 +2167,9 @@ function commitRootImpl( // Reset the priority to the previous non-sync value. setCurrentUpdatePriority(previousPriority); ReactCurrentBatchConfig.transition = prevTransition; + if (enableTransitionTracing) { + ReactCurrentBatchConfig.transitionInfo = prevTransitionInfo; + } } else { // No effects. root.current = finishedWork; @@ -2186,6 +2290,20 @@ function commitRootImpl( // If layout work was scheduled, flush it now. flushSyncCallbacks(); + if (enableTransitionTracing) { + if ( + currentPendingTransitionCallbacks !== null && + root.transitionCallbacks !== null + ) { + scheduleTransitionCallbacks( + processTransitionCallbacks, + currentPendingTransitionCallbacks, + root.transitionCallbacks, + ); + currentPendingTransitionCallbacks = null; + } + } + if (__DEV__) { if (enableDebugTracing) { logCommitStopped(); @@ -2235,13 +2353,24 @@ export function flushPassiveEffects(): boolean { const priority = lowerEventPriority(DefaultEventPriority, renderPriority); const prevTransition = ReactCurrentBatchConfig.transition; const previousPriority = getCurrentUpdatePriority(); + let prevTransitionInfo = null; + if (enableTransitionTracing) { + prevTransitionInfo = ReactCurrentBatchConfig.transitionInfo; + } + try { ReactCurrentBatchConfig.transition = 0; setCurrentUpdatePriority(priority); + if (enableTransitionTracing) { + ReactCurrentBatchConfig.transitionInfo = null; + } return flushPassiveEffectsImpl(); } finally { setCurrentUpdatePriority(previousPriority); ReactCurrentBatchConfig.transition = prevTransition; + if (enableTransitionTracing) { + ReactCurrentBatchConfig.transitionInfo = prevTransitionInfo; + } // Once passive effects have run for the tree - giving components a // chance to retain cache instances they use - release the pooled diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index ed668d9126b6f..162ba457d5490 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -15,6 +15,7 @@ import type { MutableSourceGetSnapshotFn, MutableSourceVersion, MutableSource, + StartTransitionOptions, } from 'shared/ReactTypes'; import type {SuspenseInstance} from './ReactFiberHostConfig'; import type {WorkTag} from './ReactWorkTags'; @@ -25,6 +26,7 @@ import type {RootTag} from './ReactRootTags'; import type {TimeoutHandle, NoTimeout} from './ReactFiberHostConfig'; import type {Wakeable} from 'shared/ReactTypes'; import type {Cache} from './ReactFiberCacheComponent.old'; +import type {Transitions} from './ReactFiberTracingMarkerComponent.new'; // Unwind Circular: moved from ReactFiberHooks.old export type HookType = @@ -320,6 +322,7 @@ export type TransitionTracingCallbacks = { // The following fields are only used in transition tracing in Profile builds type TransitionTracingOnlyFiberRootProperties = {| transitionCallbacks: null | TransitionTracingCallbacks, + transitionLanes: Array, |}; // Exported FiberRoot type includes all properties, @@ -369,7 +372,10 @@ export type Dispatcher = {| ): void, useDebugValue(value: T, formatterFn: ?(value: T) => mixed): void, useDeferredValue(value: T): T, - useTransition(): [boolean, (() => void) => void], + useTransition(): [ + boolean, + (callback: () => void, options?: StartTransitionOptions) => void, + ], useMutableSource( source: MutableSource, getSnapshot: MutableSourceGetSnapshotFn, diff --git a/packages/react-reconciler/src/__tests__/ReactTransitionTracing-test.js b/packages/react-reconciler/src/__tests__/ReactTransitionTracing-test.js new file mode 100644 index 0000000000000..5b4e060079908 --- /dev/null +++ b/packages/react-reconciler/src/__tests__/ReactTransitionTracing-test.js @@ -0,0 +1,491 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + * @jest-environment node + */ + +const {test} = require('jest-snapshot-serializer-raw'); + +let React; +let ReactNoop; +let Scheduler; +let act; + +let getCacheForType; +let useState; +let useTransition; +let Suspense; +let TracingMarker; +let startTransition; + +let caches; +let seededCache; + +let transitionCallbacks; + +let onTransitionStart; +let onTransitionProgress; +let onTransitionIncomplete; +let onTransitionComplete; + +let onMarkerProgress; +let onMarkerIncomplete; +let onMarkerComplete; + +describe('ReactInteractionTracing', () => { + beforeEach(() => { + jest.resetModules(); + const ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.enableTransitionTracing = true; + + React = require('react'); + ReactNoop = require('react-noop-renderer'); + Scheduler = require('scheduler'); + + act = require('jest-react').act; + + useState = React.useState; + useTransition = React.useTransition; + startTransition = React.startTransition; + Suspense = React.Suspense; + TracingMarker = React.unstable_TracingMarker; + + getCacheForType = React.unstable_getCacheForType; + + caches = []; + seededCache = null; + + onTransitionStart = jest.fn(); + onTransitionProgress = jest.fn(); + onTransitionIncomplete = jest.fn(); + onTransitionComplete = jest.fn(); + + onMarkerProgress = jest.fn(); + onMarkerIncomplete = jest.fn(); + onMarkerComplete = jest.fn(); + + transitionCallbacks = { + onTransitionStart: (name, startTime) => { + onTransitionStart({name, startTime}); + }, + onTransitionProgress: (name, startTime, currentTime, pending) => { + onTransitionProgress({ + name, + startTime, + currentTime, + pending, + }); + }, + onTransitionIncomplete: (name, startTime, deletions) => { + onTransitionIncomplete({ + name, + startTime, + deletions, + }); + }, + onTransitionComplete: (name, startTime, endTime) => { + onTransitionComplete({ + name, + startTime, + endTime, + }); + }, + onMarkerProgress: (name, marker, startTime, currentTime, pending) => { + onMarkerProgress({ + name, + marker, + startTime, + currentTime, + pending, + }); + }, + onMarkerIncomplete: (name, marker, startTime, deletions) => { + onMarkerIncomplete({ + name, + marker, + startTime, + deletions, + }); + }, + onMarkerComplete: (name, marker, startTime, endTime) => { + onMarkerComplete({ + name, + marker, + startTime, + endTime, + }); + }, + }; + }); + + function createTextCache() { + if (seededCache !== null) { + const cache = seededCache; + seededCache = null; + return cache; + } + + const data = new Map(); + const cache = { + data, + resolve(text) { + const record = data.get(text); + + if (record === undefined) { + const newRecord = { + status: 'resolved', + value: text, + }; + data.set(text, newRecord); + } else if (record.status === 'pending') { + const thenable = record.value; + record.status = 'resolved'; + record.value = text; + thenable.pings.forEach(t => t()); + } + }, + reject(text, error) { + const record = data.get(text); + if (record === undefined) { + const newRecord = { + status: 'rejected', + value: error, + }; + data.set(text, newRecord); + } else if (record.status === 'pending') { + const thenable = record.value; + record.status = 'rejected'; + record.value = error; + thenable.pings.forEach(t => t()); + } + }, + }; + caches.push(cache); + return cache; + } + + function readText(text) { + const textCache = getCacheForType(createTextCache); + const record = textCache.data.get(text); + if (record !== undefined) { + switch (record.status) { + case 'pending': + Scheduler.unstable_yieldValue(`Suspend [${text}]`); + throw record.value; + case 'rejected': + Scheduler.unstable_yieldValue(`Error [${text}]`); + throw record.value; + case 'resolved': + return record.value; + } + } else { + Scheduler.unstable_yieldValue(`Suspend [${text}]`); + + const thenable = { + pings: [], + then(resolve) { + if (newRecord.status === 'pending') { + thenable.pings.push(resolve); + } else { + Promise.resolve().then(() => resolve(newRecord.value)); + } + }, + }; + + const newRecord = { + status: 'pending', + value: thenable, + }; + textCache.data.set(text, newRecord); + + throw thenable; + } + } + + function AsyncText({text}) { + const fullText = readText(text); + Scheduler.unstable_yieldValue(fullText); + return fullText; + } + + function Text({text}) { + Scheduler.unstable_yieldValue(text); + return text; + } + + function seedNextTextCache(text) { + if (seededCache === null) { + seededCache = createTextCache(); + } + seededCache.resolve(text); + } + + function resolveMostRecentTextCache(text) { + if (caches.length === 0) { + throw Error('Cache does not exist'); + } else { + // Resolve the most recently created cache. An older cache can by + // resolved with `caches[index].resolve(text)`. + caches[caches.length - 1].resolve(text); + } + } + + const resolveText = resolveMostRecentTextCache; + + function rejectMostRecentTextCache(text, error) { + if (caches.length === 0) { + throw Error('Cache does not exist.'); + } else { + // Resolve the most recently created cache. An older cache can by + // resolved with `caches[index].reject(text, error)`. + caches[caches.length - 1].reject(text, error); + } + } + + const rejectText = rejectMostRecentTextCache; + + function advanceTimers(ms) { + // Note: This advances Jest's virtual time but not React's. Use + // ReactNoop.expire for that. + if (typeof ms !== 'number') { + throw new Error('Must specify ms'); + } + jest.advanceTimersByTime(ms); + // Wait until the end of the current tick + // We cannot use a timer since we're faking them + return Promise.resolve().then(() => {}); + } + + it('should correctly trace interactions for async roots', async () => { + let navigateToPageTwo; + function App() { + const [navigate, setNavigate] = useState(false); + navigateToPageTwo = () => { + setNavigate(true); + }; + + return ( +
+ {navigate ? ( + + } + name="suspense page"> + + + + ) : ( + + )} +
+ ); + } + + const root = ReactNoop.createRoot({transitionCallbacks}); + await act(async () => { + root.render(); + ReactNoop.expire(1000); + await advanceTimers(1000); + + expect(Scheduler).toFlushAndYield(['Page One']); + }); + + await act(async () => { + startTransition(() => navigateToPageTwo(), {name: 'page transition'}); + + ReactNoop.expire(1000); + await advanceTimers(1000); + + expect(Scheduler).toFlushAndYield(['Suspend [Page Two]', 'Loading...']); + + ReactNoop.expire(1000); + await advanceTimers(1000); + await resolveText('Page Two'); + + expect(Scheduler).toFlushAndYield(['Page Two']); + }); + + expect(onTransitionStart).toHaveBeenCalledTimes(1); + expect(onTransitionStart.mock.calls[0][0]).toEqual({ + name: 'page transition', + startTime: 1000, + }); + + expect(onTransitionProgress).toHaveBeenCalledTimes(2); + expect(onTransitionProgress.mock.calls[0][0]).toEqual({ + name: 'page transition', + startTime: 1000, + currentTime: 2000, + pending: [{name: 'suspense page'}], + }); + expect(onTransitionProgress.mock.calls[1][0]).toEqual({ + name: 'page transition', + startTime: 1000, + currentTime: 3000, + pending: [], + }); + + expect(onMarkerProgress).toHaveBeenCalledTimes(2); + expect(onMarkerProgress.mock.calls[0][0]).toEqual({ + name: 'page transition', + marker: 'page loaded', + startTime: 1000, + currentTime: 2000, + pending: [{name: 'suspense page'}], + }); + expect(onMarkerProgress.mock.calls[1][0]).toEqual({ + name: 'page transition', + marker: 'page loaded', + startTime: 1000, + currentTime: 3000, + pending: [], + }); + + expect(onTransitionComplete).toHaveBeenCalledTimes(1); + expect(onTransitionComplete.mock.calls[0][0]).toEqual({ + name: 'page transition', + startTime: 1000, + endTime: 3000, + }); + expect(onMarkerComplete).toHaveBeenCalledTimes(1); + expect(onMarkerComplete.mock.calls[0][0]).toEqual({ + name: 'page transition', + marker: 'page loaded', + startTime: 1000, + endTime: 3000, + }); + }); + + it.skip('tracing marker leaf components', async () => { + let navigateToPageTwo; + function App() { + const [navigate, setNavigate] = useState(false); + navigateToPageTwo = () => { + setNavigate(true); + }; + + return ( +
+ {navigate ? ( + <> + }> + + + + }> + + + + + ) : ( + + )} +
+ ); + } + + const root = ReactNoop.createRoot({transitionCallbacks}); + await act(async () => { + root.render(); + ReactNoop.expire(1000); + await advanceTimers(1000); + + expect(Scheduler).toFlushAndYield(['Page One']); + }); + + await act(async () => { + startTransition(() => navigateToPageTwo(), {name: 'page transition'}); + + ReactNoop.expire(1000); + await advanceTimers(1000); + + expect(Scheduler).toFlushAndYield([ + 'Suspend [Subtree One]', + 'Loading One...', + 'Suspend [Subtree Two]', + 'Loading Two...', + ]); + + ReactNoop.expire(1000); + await advanceTimers(1000); + await resolveText('Subtree One'); + + expect(Scheduler).toFlushAndYield(['Subtree One']); + + ReactNoop.expire(1000); + await advanceTimers(1000); + await resolveText('Subtree Two'); + expect(Scheduler).toFlushAndYield(['Subtree Two']); + }); + + expect(onTransitionStart).toHaveBeenCalledTimes(1); + expect(onTransitionStart.mock.calls[0][0]).toEqual({ + name: 'page transition', + startTime: 1000, + }); + + expect(onTransitionProgress).toHaveBeenCalledTimes(3); + expect(onTransitionProgress.mock.calls[0][0]).toEqual({ + name: 'page transition', + startTime: 1000, + currentTime: 2000, + pending: [{name: 'Suspense One'}, {name: 'Suspense Two'}], + }); + expect(onTransitionProgress.mock.calls[1][0]).toEqual({ + name: 'page transition', + startTime: 1000, + currentTime: 3000, + pending: [{name: 'Suspense Two'}], + }); + expect(onTransitionProgress.mock.calls[2][0]).toEqual({ + name: 'page transition', + startTime: 1000, + currentTime: 4000, + pending: [], + }); + + expect(onMarkerProgress).toHaveBeenCalledTimes(2); + expect(onMarkerProgress.mock.calls[0][0]).toEqual({ + name: 'page transition', + marker: 'Subtree One Loaded', + startTime: 3000, + currentTime: 3000, + pending: [], + }); + expect(onMarkerProgress.mock.calls[1][0]).toEqual({ + name: 'page transition', + marker: 'Subtree Two Loaded', + startTime: 4000, + currentTime: 4000, + pending: [], + }); + + expect(onTransitionComplete).toHaveBeenCalledTimes(1); + expect(onTransitionComplete.mock.calls[0][0]).toEqual({ + name: 'page transition', + startTime: 1000, + endTime: 4000, + }); + expect(onMarkerComplete).toHaveBeenCalledTimes(2); + expect(onMarkerComplete.mock.calls[0][0]).toEqual({ + name: 'page transition', + marker: 'Subtree One Loaded', + startTime: 3000, + endTime: 3000, + }); + expect(onMarkerComplete.mock.calls[1][0]).toEqual({ + name: 'page transition', + marker: 'Subtree Two Loaded', + startTime: 4000, + endTime: 4000, + }); + }); +}); diff --git a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js index 6535d8d3fdec3..96eca89e29e28 100644 --- a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js @@ -189,3 +189,11 @@ export const didNotFindHydratableTextInstance = export const didNotFindHydratableSuspenseInstance = $$$hostConfig.didNotFindHydratableSuspenseInstance; export const errorHydratingContainer = $$$hostConfig.errorHydratingContainer; + +// ------------------- +// Transition Tracing +// (optional) +// ------------------- +export const getCurrentEventStartTime = $$$hostConfig.getCurrentEventStartTime; +export const scheduleTransitionCallbacks = + $$$hostConfig.scheduleTransitionCallbacks; diff --git a/packages/react-server/src/ReactFizzHooks.js b/packages/react-server/src/ReactFizzHooks.js index 5997f1b02b902..c3ffa8cd6abd8 100644 --- a/packages/react-server/src/ReactFizzHooks.js +++ b/packages/react-server/src/ReactFizzHooks.js @@ -14,6 +14,7 @@ import type { MutableSourceGetSnapshotFn, MutableSourceSubscribeFn, ReactContext, + StartTransitionOptions, } from 'shared/ReactTypes'; import type {ResponseState} from './ReactServerFormatConfig'; @@ -505,7 +506,10 @@ function unsupportedStartTransition() { throw new Error('startTransition cannot be called during server rendering.'); } -function useTransition(): [boolean, (callback: () => void) => void] { +function useTransition(): [ + boolean, + (callback: () => void, options?: StartTransitionOptions) => void, +] { resolveCurrentlyRenderingComponent(); return [false, unsupportedStartTransition]; } diff --git a/packages/react-test-renderer/src/ReactTestHostConfig.js b/packages/react-test-renderer/src/ReactTestHostConfig.js index 840912db30886..d07823de23eed 100644 --- a/packages/react-test-renderer/src/ReactTestHostConfig.js +++ b/packages/react-test-renderer/src/ReactTestHostConfig.js @@ -318,3 +318,15 @@ export function detachDeletedInstance(node: Instance): void { export function logRecoverableError(error: mixed): void { // noop } + +export function getCurrentEventStartTime() { + // noop +} + +export function scheduleTransitionCallbacks( + callback, + pendingTransitions, + callbacks, +) { + // noop +} diff --git a/packages/react/src/ReactCurrentBatchConfig.js b/packages/react/src/ReactCurrentBatchConfig.js index cca41ae4b3492..d3a296295f73a 100644 --- a/packages/react/src/ReactCurrentBatchConfig.js +++ b/packages/react/src/ReactCurrentBatchConfig.js @@ -8,10 +8,12 @@ */ import type {Fiber} from 'react-reconciler/src/ReactInternalTypes'; +import type {Transition} from 'react-reconciler/src/ReactFiberTracingMarkerComponent.new'; type BatchConfig = { transition: number, _updatedFibers?: Set, + transitionInfo: Transition | null, }; /** * Keeps track of the current batch's configuration such as how long an update @@ -19,6 +21,7 @@ type BatchConfig = { */ const ReactCurrentBatchConfig: BatchConfig = { transition: 0, + transitionInfo: null, }; if (__DEV__) { diff --git a/packages/react/src/ReactHooks.js b/packages/react/src/ReactHooks.js index 12a5350083f38..9dc7a98589e4e 100644 --- a/packages/react/src/ReactHooks.js +++ b/packages/react/src/ReactHooks.js @@ -13,6 +13,7 @@ import type { MutableSourceGetSnapshotFn, MutableSourceSubscribeFn, ReactContext, + StartTransitionOptions, } from 'shared/ReactTypes'; import ReactCurrentDispatcher from './ReactCurrentDispatcher'; @@ -158,7 +159,10 @@ export function useDebugValue( export const emptyObject = {}; -export function useTransition(): [boolean, (() => void) => void] { +export function useTransition(): [ + boolean, + (callback: () => void, options?: StartTransitionOptions) => void, +] { const dispatcher = resolveDispatcher(); return dispatcher.useTransition(); } diff --git a/packages/react/src/ReactStartTransition.js b/packages/react/src/ReactStartTransition.js index 3f9c9efe71491..b9571eb830f97 100644 --- a/packages/react/src/ReactStartTransition.js +++ b/packages/react/src/ReactStartTransition.js @@ -6,17 +6,43 @@ * * @flow */ +import type {StartTransitionOptions} from 'shared/ReactTypes'; import ReactCurrentBatchConfig from './ReactCurrentBatchConfig'; -import {warnOnSubscriptionInsideStartTransition} from 'shared/ReactFeatureFlags'; +import { + warnOnSubscriptionInsideStartTransition, + enableTransitionTracing, +} from 'shared/ReactFeatureFlags'; -export function startTransition(scope: () => void) { +export function startTransition( + scope: () => void, + options?: StartTransitionOptions, +) { const prevTransition = ReactCurrentBatchConfig.transition; ReactCurrentBatchConfig.transition = 1; + + let prevTransitionInfo = null; + if (enableTransitionTracing) { + prevTransitionInfo = ReactCurrentBatchConfig.transitionInfo; + if (options !== undefined && options.name !== undefined) { + ReactCurrentBatchConfig.transitionInfo = { + name: options.name, + startTime: -1, + }; + } else { + ReactCurrentBatchConfig.transitionInfo = null; + } + } + try { scope(); } finally { ReactCurrentBatchConfig.transition = prevTransition; + + if (enableTransitionTracing) { + ReactCurrentBatchConfig.transitionInfo = prevTransitionInfo; + } + if (__DEV__) { if ( prevTransition !== 1 && diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index 43f42bddb91d3..066d20552d6fc 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -171,3 +171,7 @@ export type OffscreenMode = | 'hidden' | 'unstable-defer-without-hiding' | 'visible'; + +export type StartTransitionOptions = { + name?: string, +};