From efc6f2e0fe10c516fb3da7a517b166749c605ccc Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Thu, 16 May 2019 13:19:29 -0700 Subject: [PATCH] Track which fibers scheduled the current render work --- .../src/ReactFiberCommitWork.js | 31 +- .../react-reconciler/src/ReactFiberRoot.js | 21 +- .../src/ReactFiberScheduler.js | 132 +++-- .../src/ReactFiberUnwindWork.js | 11 + .../__tests__/ReactUpdaters-test.internal.js | 490 ++++++++++++++++++ packages/shared/ReactFeatureFlags.js | 3 + .../forks/ReactFeatureFlags.native-fb.js | 1 + .../forks/ReactFeatureFlags.native-oss.js | 1 + .../forks/ReactFeatureFlags.persistent.js | 1 + .../forks/ReactFeatureFlags.test-renderer.js | 1 + .../ReactFeatureFlags.test-renderer.www.js | 1 + .../shared/forks/ReactFeatureFlags.www.js | 1 + 12 files changed, 653 insertions(+), 41 deletions(-) create mode 100644 packages/react-reconciler/src/__tests__/ReactUpdaters-test.internal.js diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.js b/packages/react-reconciler/src/ReactFiberCommitWork.js index b8043c2b8297b..e8a75e1d234a3 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.js @@ -28,6 +28,7 @@ import { enableSchedulerTracing, enableProfilerTimer, enableSuspenseServerRenderer, + enableUpdaterTracking, enableEventAPI, } from 'shared/ReactFeatureFlags'; import { @@ -104,6 +105,7 @@ import { captureCommitPhaseError, requestCurrentTime, resolveRetryThenable, + restorePendingUpdaters, } from './ReactFiberScheduler'; import { NoEffect as NoHookEffect, @@ -1169,7 +1171,12 @@ function commitDeletion(current: Fiber): void { detachFiber(current); } -function commitWork(current: Fiber | null, finishedWork: Fiber): void { +function commitWork( + finishedRoot: FiberRoot, + current: Fiber | null, + finishedWork: Fiber, + committedExpirationTime: ExpirationTime, +): void { if (!supportsMutation) { switch (finishedWork.tag) { case FunctionComponent: @@ -1185,7 +1192,11 @@ function commitWork(current: Fiber | null, finishedWork: Fiber): void { return; } case SuspenseComponent: { - commitSuspenseComponent(finishedWork); + commitSuspenseComponent( + finishedRoot, + finishedWork, + committedExpirationTime, + ); return; } } @@ -1259,7 +1270,11 @@ function commitWork(current: Fiber | null, finishedWork: Fiber): void { return; } case SuspenseComponent: { - commitSuspenseComponent(finishedWork); + commitSuspenseComponent( + finishedRoot, + finishedWork, + committedExpirationTime, + ); return; } case IncompleteClassComponent: { @@ -1278,7 +1293,11 @@ function commitWork(current: Fiber | null, finishedWork: Fiber): void { } } -function commitSuspenseComponent(finishedWork: Fiber) { +function commitSuspenseComponent( + finishedRoot: FiberRoot, + finishedWork: Fiber, + committedExpirationTime: ExpirationTime, +) { let newState: SuspenseState | null = finishedWork.memoizedState; let newDidTimeout; @@ -1321,6 +1340,10 @@ function commitSuspenseComponent(finishedWork: Fiber) { if (enableSchedulerTracing) { retry = Schedule_tracing_wrap(retry); } + if (enableUpdaterTracking) { + // If we have pending work still, restore the original updaters + restorePendingUpdaters(finishedRoot, committedExpirationTime); + } retryCache.add(thenable); thenable.then(retry, retry); } diff --git a/packages/react-reconciler/src/ReactFiberRoot.js b/packages/react-reconciler/src/ReactFiberRoot.js index 93a90f87d0b2e..14e8b2f19f68d 100644 --- a/packages/react-reconciler/src/ReactFiberRoot.js +++ b/packages/react-reconciler/src/ReactFiberRoot.js @@ -17,7 +17,10 @@ import type {Interaction} from 'scheduler/src/Tracing'; import {noTimeout} from './ReactFiberHostConfig'; import {createHostRootFiber} from './ReactFiber'; import {NoWork} from './ReactFiberExpirationTime'; -import {enableSchedulerTracing} from 'shared/ReactFeatureFlags'; +import { + enableSchedulerTracing, + enableUpdaterTracking, +} from 'shared/ReactFeatureFlags'; import {unstable_getThreadID} from 'scheduler/tracing'; // TODO: This should be lifted into the renderer. @@ -30,6 +33,9 @@ export type Batch = { export type PendingInteractionMap = Map>; +// Map of expiration time to all pending "updaters" which in turn is a map of Fibers to reference counts. +export type PendingUpdatersMap = Map>; + type BaseFiberRootProperties = {| // The type of root (legacy, batched, concurrent, etc.) tag: RootTag, @@ -83,6 +89,13 @@ type ProfilingOnlyFiberRootProperties = {| pendingInteractionMap: PendingInteractionMap, |}; +// The following attributes are only used by DevTools and are only present in DEV builds. +// They enable DevTools Profiler UI to show which Fiber(s) scheduled a given commit. +type UpdaterTrackingOnlyFiberRootProperties = {| + memoizedUpdaters: Set, + pendingUpdatersMap: PendingUpdatersMap, +|}; + // Exported FiberRoot type includes all properties, // To avoid requiring potentially error-prone :any casts throughout the project. // Profiling properties are only safe to access in profiling builds (when enableSchedulerTracing is true). @@ -91,6 +104,7 @@ type ProfilingOnlyFiberRootProperties = {| export type FiberRoot = { ...BaseFiberRootProperties, ...ProfilingOnlyFiberRootProperties, + ...UpdaterTrackingOnlyFiberRootProperties, }; function FiberRootNode(containerInfo, tag, hydrate) { @@ -117,6 +131,11 @@ function FiberRootNode(containerInfo, tag, hydrate) { this.memoizedInteractions = new Set(); this.pendingInteractionMap = new Map(); } + + if (enableUpdaterTracking) { + this.memoizedUpdaters = new Set(); + this.pendingUpdatersMap = new Map(); + } } export function createFiberRoot( diff --git a/packages/react-reconciler/src/ReactFiberScheduler.js b/packages/react-reconciler/src/ReactFiberScheduler.js index fbfac5a277d95..3cda13dbf1e4f 100644 --- a/packages/react-reconciler/src/ReactFiberScheduler.js +++ b/packages/react-reconciler/src/ReactFiberScheduler.js @@ -19,6 +19,7 @@ import type {Interaction} from 'scheduler/src/Tracing'; import { warnAboutDeprecatedLifecycles, enableUserTimingAPI, + enableUpdaterTracking, enableSuspenseServerRenderer, replayFailedUnitOfWorkWithInvokeGuardedCallback, enableProfilerTimer, @@ -337,6 +338,16 @@ export function scheduleUpdateOnFiber( return; } + if (enableUpdaterTracking) { + const pendingUpdatersMap = root.pendingUpdatersMap; + let updaters = pendingUpdatersMap.get(expirationTime); + if (updaters == null) { + updaters = new Set(); + pendingUpdatersMap.set(expirationTime, updaters); + } + updaters.add(fiber); + } + root.pingTime = NoWork; checkForInterruption(fiber, expirationTime); @@ -1292,6 +1303,12 @@ function commitRootImpl(root) { // This usually means we've finished all the work, but it can also happen // when something gets downprioritized during render, like a hidden tree. root.lastPendingTime = firstPendingTimeBeforeCommit; + + if (enableSchedulerTracing) { + if (firstPendingTimeBeforeCommit !== NoWork) { + restorePendingUpdaters(root, root.lastPendingTime); + } + } } if (root === workInProgressRoot) { @@ -1377,7 +1394,13 @@ function commitRootImpl(root) { nextEffect = firstEffect; do { if (__DEV__) { - invokeGuardedCallback(null, commitMutationEffects, null); + invokeGuardedCallback( + null, + commitMutationEffects, + null, + root, + expirationTime, + ); if (hasCaughtError()) { invariant(nextEffect !== null, 'Should be working on an effect.'); const error = clearCaughtError(); @@ -1386,7 +1409,7 @@ function commitRootImpl(root) { } } else { try { - commitMutationEffects(); + commitMutationEffects(root, expirationTime); } catch (error) { invariant(nextEffect !== null, 'Should be working on an effect.'); captureCommitPhaseError(nextEffect, error); @@ -1540,7 +1563,10 @@ function commitBeforeMutationEffects() { } } -function commitMutationEffects() { +function commitMutationEffects( + root: FiberRoot, + committedExpirationTime: ExpirationTime, +) { // TODO: Should probably move the bulk of this function to commitWork. while (nextEffect !== null) { setCurrentDebugFiberInDEV(nextEffect); @@ -1582,12 +1608,12 @@ function commitMutationEffects() { // Update const current = nextEffect.alternate; - commitWork(current, nextEffect); + commitWork(root, current, nextEffect, committedExpirationTime); break; } case Update: { const current = nextEffect.alternate; - commitWork(current, nextEffect); + commitWork(root, current, nextEffect, committedExpirationTime); break; } case Deletion: { @@ -2161,6 +2187,24 @@ function warnIfNotCurrentlyActingUpdatesInDEV(fiber: Fiber): void { } } +export function restorePendingUpdaters( + root: FiberRoot, + expirationTime: ExpirationTime, +): void { + if (!enableUpdaterTracking) { + return; + } + const pendingUpdatersMap = root.pendingUpdatersMap; + let updaters = pendingUpdatersMap.get(expirationTime); + if (updaters == null) { + updaters = new Set(); + pendingUpdatersMap.set(expirationTime, updaters); + } + root.memoizedUpdaters.forEach(schedulingFiber => { + ((updaters: any): Set).add(schedulingFiber); + }); +} + export const warnIfNotCurrentlyActingUpdatesInDev = warnIfNotCurrentlyActingUpdatesInDEV; let componentsWithSuspendedDiscreteUpdates = null; @@ -2277,42 +2321,58 @@ function schedulePendingInteraction(root, expirationTime) { function startWorkOnPendingInteraction(root, expirationTime) { // This is called when new work is started on a root. - if (!enableSchedulerTracing) { - return; - } - // Determine which interactions this batch of work currently includes, So that - // we can accurately attribute time spent working on it, And so that cascading - // work triggered during the render phase will be associated with it. - const interactions: Set = new Set(); - root.pendingInteractionMap.forEach( - (scheduledInteractions, scheduledExpirationTime) => { + if (enableUpdaterTracking) { + const memoizedUpdaters: Set = new Set(); + const pendingUpdatersMap = root.pendingUpdatersMap; + pendingUpdatersMap.forEach((updaters, scheduledExpirationTime) => { if (scheduledExpirationTime >= expirationTime) { - scheduledInteractions.forEach(interaction => - interactions.add(interaction), - ); + pendingUpdatersMap.delete(scheduledExpirationTime); + updaters.forEach(fiber => memoizedUpdaters.add(fiber)); } - }, - ); + }); - // Store the current set of interactions on the FiberRoot for a few reasons: - // We can re-use it in hot functions like renderRoot() without having to - // recalculate it. We will also use it in commitWork() to pass to any Profiler - // onRender() hooks. This also provides DevTools with a way to access it when - // the onCommitRoot() hook is called. - root.memoizedInteractions = interactions; + // Store the current set of interactions on the FiberRoot for a few reasons: + // We can re-use it in hot functions like renderRoot() without having to + // recalculate it. This also provides DevTools with a way to access it when + // the onCommitRoot() hook is called. + root.memoizedUpdaters = memoizedUpdaters; + } - if (interactions.size > 0) { - const subscriber = __subscriberRef.current; - if (subscriber !== null) { - const threadID = computeThreadID(root, expirationTime); - try { - subscriber.onWorkStarted(interactions, threadID); - } catch (error) { - // If the subscriber throws, rethrow it in a separate task - scheduleCallback(ImmediatePriority, () => { - throw error; - }); + if (enableSchedulerTracing) { + // Determine which interactions this batch of work currently includes, So that + // we can accurately attribute time spent working on it, And so that cascading + // work triggered during the render phase will be associated with it. + const interactions: Set = new Set(); + root.pendingInteractionMap.forEach( + (scheduledInteractions, scheduledExpirationTime) => { + if (scheduledExpirationTime >= expirationTime) { + scheduledInteractions.forEach(interaction => + interactions.add(interaction), + ); + } + }, + ); + + // Store the current set of interactions on the FiberRoot for a few reasons: + // We can re-use it in hot functions like renderRoot() without having to + // recalculate it. We will also use it in commitWork() to pass to any Profiler + // onRender() hooks. This also provides DevTools with a way to access it when + // the onCommitRoot() hook is called. + root.memoizedInteractions = interactions; + + if (interactions.size > 0) { + const subscriber = __subscriberRef.current; + if (subscriber !== null) { + const threadID = computeThreadID(root, expirationTime); + try { + subscriber.onWorkStarted(interactions, threadID); + } catch (error) { + // If the subscriber throws, rethrow it in a separate task + scheduleCallback(ImmediatePriority, () => { + throw error; + }); + } } } } diff --git a/packages/react-reconciler/src/ReactFiberUnwindWork.js b/packages/react-reconciler/src/ReactFiberUnwindWork.js index 1d4a4cbe236eb..46a4eb042eaaa 100644 --- a/packages/react-reconciler/src/ReactFiberUnwindWork.js +++ b/packages/react-reconciler/src/ReactFiberUnwindWork.js @@ -40,6 +40,7 @@ import { import { enableSchedulerTracing, enableSuspenseServerRenderer, + enableUpdaterTracking, enableEventAPI, } from 'shared/ReactFeatureFlags'; import {NoMode, BatchedMode} from './ReactTypeOfMode'; @@ -76,6 +77,7 @@ import { pingSuspendedRoot, resolveRetryThenable, checkForWrongSuspensePriorityInDEV, + restorePendingUpdaters, } from './ReactFiberScheduler'; import invariant from 'shared/invariant'; @@ -187,6 +189,10 @@ function attachPingListener( if (enableSchedulerTracing) { ping = Schedule_tracing_wrap(ping); } + if (enableUpdaterTracking) { + // If we have pending work still, restore the original updaters + restorePendingUpdaters(root, renderExpirationTime); + } thenable.then(ping, ping); } } @@ -203,6 +209,11 @@ function throwException( // Its effect list is no longer valid. sourceFiber.firstEffect = sourceFiber.lastEffect = null; + if (enableUpdaterTracking) { + // If we have pending work still, restore the original updaters + restorePendingUpdaters(root, renderExpirationTime); + } + if ( value !== null && typeof value === 'object' && diff --git a/packages/react-reconciler/src/__tests__/ReactUpdaters-test.internal.js b/packages/react-reconciler/src/__tests__/ReactUpdaters-test.internal.js new file mode 100644 index 0000000000000..ea18b46a5587f --- /dev/null +++ b/packages/react-reconciler/src/__tests__/ReactUpdaters-test.internal.js @@ -0,0 +1,490 @@ +/** + * 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 + */ + +'use strict'; + +let React; +let ReactFeatureFlags; +let ReactDOM; +let Scheduler; +let TestUtils; +let mockDevToolsHook; +let allSchedulerTags; +let allSchedulerTypes; + +describe('updaters', () => { + beforeEach(() => { + jest.resetModules(); + + allSchedulerTags = []; + allSchedulerTypes = []; + + mockDevToolsHook = { + injectInternals: jest.fn(() => {}), + onCommitRoot: jest.fn(fiberRoot => { + Scheduler.yieldValue('onCommitRoot'); + const schedulerTags = []; + const schedulerTypes = []; + fiberRoot.memoizedUpdaters.forEach(fiber => { + schedulerTags.push(fiber.tag); + schedulerTypes.push(fiber.elementType); + }); + allSchedulerTags.push(schedulerTags); + allSchedulerTypes.push(schedulerTypes); + }), + onCommitUnmount: jest.fn(() => {}), + isDevToolsPresent: true, + }; + + jest.mock( + 'react-reconciler/src/ReactFiberDevToolsHook', + () => mockDevToolsHook, + ); + + ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.enableUpdaterTracking = true; + ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false; + + React = require('react'); + ReactDOM = require('react-dom'); + Scheduler = require('scheduler'); + TestUtils = require('react-dom/test-utils'); + }); + + it('should report the (host) root as the scheduler for root-level render', () => { + const {HostRoot} = require('shared/ReactWorkTags'); + + const Parent = () => ; + const Child = () => null; + const container = document.createElement('div'); + + TestUtils.act(() => { + ReactDOM.render(, container); + }); + expect(allSchedulerTags).toHaveLength(1); + expect(allSchedulerTags[0]).toHaveLength(1); + expect(allSchedulerTags[0]).toContain(HostRoot); + + TestUtils.act(() => { + ReactDOM.render(, container); + }); + expect(allSchedulerTags).toHaveLength(2); + expect(allSchedulerTags[1]).toHaveLength(1); + expect(allSchedulerTags[1]).toContain(HostRoot); + }); + + it('should report a function component as the scheduler for a hooks update', () => { + let scheduleForA = null; + let scheduleForB = null; + + const Parent = () => ( + + + + + ); + const SchedulingComponentA = () => { + const [count, setCount] = React.useState(0); + scheduleForA = () => setCount(prevCount => prevCount + 1); + return ; + }; + const SchedulingComponentB = () => { + const [count, setCount] = React.useState(0); + scheduleForB = () => setCount(prevCount => prevCount + 1); + return ; + }; + const Child = () => null; + + TestUtils.act(() => { + ReactDOM.render(, document.createElement('div')); + }); + expect(scheduleForA).not.toBeNull(); + expect(scheduleForB).not.toBeNull(); + expect(allSchedulerTypes).toHaveLength(1); + + TestUtils.act(scheduleForA); + expect(allSchedulerTypes).toHaveLength(2); + expect(allSchedulerTypes[1]).toHaveLength(1); + expect(allSchedulerTypes[1]).toContain(SchedulingComponentA); + + TestUtils.act(scheduleForB); + expect(allSchedulerTypes).toHaveLength(3); + expect(allSchedulerTypes[2]).toHaveLength(1); + expect(allSchedulerTypes[2]).toContain(SchedulingComponentB); + }); + + it('should report a class component as the scheduler for a setState update', () => { + const Parent = () => ; + class SchedulingComponent extends React.Component { + state = {}; + render() { + instance = this; + return ; + } + } + const Child = () => null; + let instance; + TestUtils.act(() => { + ReactDOM.render(, document.createElement('div')); + }); + expect(allSchedulerTypes).toHaveLength(1); + + expect(instance).not.toBeNull(); + TestUtils.act(() => { + instance.setState({}); + }); + expect(allSchedulerTypes).toHaveLength(2); + expect(allSchedulerTypes[1]).toHaveLength(1); + expect(allSchedulerTypes[1]).toContain(SchedulingComponent); + }); + + it('should cover cascading updates', () => { + let triggerActiveCascade = null; + let triggerPassiveCascade = null; + + const Parent = () => ; + const SchedulingComponent = () => { + const [cascade, setCascade] = React.useState(null); + triggerActiveCascade = () => setCascade('active'); + triggerPassiveCascade = () => setCascade('passive'); + return ; + }; + const CascadingChild = ({cascade}) => { + const [count, setCount] = React.useState(0); + Scheduler.yieldValue(`CascadingChild ${count}`); + React.useLayoutEffect( + () => { + if (cascade === 'active') { + setCount(prevCount => prevCount + 1); + } + return () => {}; + }, + [cascade], + ); + React.useEffect( + () => { + if (cascade === 'passive') { + setCount(prevCount => prevCount + 1); + } + return () => {}; + }, + [cascade], + ); + return count; + }; + + const root = ReactDOM.unstable_createRoot(document.createElement('div')); + TestUtils.act(() => { + root.render(); + expect(Scheduler).toFlushAndYieldThrough([ + 'CascadingChild 0', + 'onCommitRoot', + ]); + }); + expect(triggerActiveCascade).not.toBeNull(); + expect(triggerPassiveCascade).not.toBeNull(); + expect(allSchedulerTypes).toHaveLength(1); + + TestUtils.act(() => { + triggerActiveCascade(); + expect(Scheduler).toFlushAndYieldThrough([ + 'CascadingChild 0', + 'onCommitRoot', + 'CascadingChild 1', + 'onCommitRoot', + ]); + }); + expect(allSchedulerTypes).toHaveLength(3); + expect(allSchedulerTypes[1]).toHaveLength(1); + expect(allSchedulerTypes[1]).toContain(SchedulingComponent); + expect(allSchedulerTypes[2]).toHaveLength(1); + expect(allSchedulerTypes[2]).toContain(CascadingChild); + + TestUtils.act(() => { + triggerPassiveCascade(); + expect(Scheduler).toFlushAndYieldThrough([ + 'CascadingChild 1', + 'onCommitRoot', + 'CascadingChild 2', + 'onCommitRoot', + ]); + }); + expect(allSchedulerTypes).toHaveLength(5); + expect(allSchedulerTypes[3]).toHaveLength(1); + expect(allSchedulerTypes[3]).toContain(SchedulingComponent); + expect(allSchedulerTypes[4]).toHaveLength(1); + expect(allSchedulerTypes[4]).toContain(CascadingChild); + + // Verify no outstanding flushes + Scheduler.flushAll(); + }); + + it('should cover suspense pings', async done => { + let data = null; + let resolver = null; + let promise = null; + const fakeCacheRead = () => { + if (data === null) { + promise = new Promise(resolve => { + resolver = resolvedData => { + data = resolvedData; + resolve(resolvedData); + }; + }); + throw promise; + } else { + return data; + } + }; + const Parent = () => ( + }> + + + ); + const Fallback = () => null; + let setShouldSuspend = null; + const Suspender = ({suspend}) => { + const tuple = React.useState(false); + setShouldSuspend = tuple[1]; + if (tuple[0] === true) { + return fakeCacheRead(); + } else { + return null; + } + }; + + TestUtils.act(() => { + ReactDOM.render(, document.createElement('div')); + expect(Scheduler).toHaveYielded(['onCommitRoot']); + }); + expect(setShouldSuspend).not.toBeNull(); + expect(allSchedulerTypes).toHaveLength(1); + + TestUtils.act(() => { + setShouldSuspend(true); + expect(Scheduler).toFlushAndYieldThrough(['onCommitRoot']); + }); + expect(allSchedulerTypes).toHaveLength(2); + expect(allSchedulerTypes[1]).toHaveLength(1); + expect(allSchedulerTypes[1]).toContain(Suspender); + + expect(resolver).not.toBeNull(); + await TestUtils.act(() => { + resolver('abc'); + return promise; + }); + expect(Scheduler).toHaveYielded(['onCommitRoot']); + expect(allSchedulerTypes).toHaveLength(3); + expect(allSchedulerTypes[2]).toHaveLength(1); + expect(allSchedulerTypes[2]).toContain(Suspender); + + // Verify no outstanding flushes + Scheduler.flushAll(); + + done(); + }); + + it('should cover hidden/offscreen work', async done => { + let setIncludeHiddenTree = null; + + const Parent = () => ; + const SchedulingComponent = () => { + const tuple = React.useState(false); + setIncludeHiddenTree = tuple[1]; + return ( + + + {tuple[0] && ( + + )} + + ); + }; + const Hidden = () => { + Scheduler.yieldValue('Hidden'); + return null; + }; + const NotHidden = () => { + Scheduler.yieldValue('NotHidden'); + return null; + }; + + const root = ReactDOM.unstable_createRoot(document.createElement('div')); + TestUtils.act(() => { + root.render(); + expect(Scheduler).toFlushAndYieldThrough(['NotHidden', 'onCommitRoot']); + }); + expect(allSchedulerTypes).toHaveLength(1); + + expect(setIncludeHiddenTree).not.toBeNull(); + TestUtils.act(() => { + setIncludeHiddenTree(true); + expect(Scheduler).toFlushAndYieldThrough(['NotHidden', 'onCommitRoot']); + expect(allSchedulerTypes).toHaveLength(2); + expect(allSchedulerTypes[1]).toHaveLength(1); + expect(allSchedulerTypes[1]).toContain(SchedulingComponent); + + expect(Scheduler).toFlushAndYieldThrough(['Hidden', 'onCommitRoot']); + expect(allSchedulerTypes).toHaveLength(3); + expect(allSchedulerTypes[2]).toHaveLength(1); + expect(allSchedulerTypes[2]).toContain(SchedulingComponent); + }); + + // Verify no outstanding flushes + Scheduler.flushAll(); + + done(); + }); + + it('should cover error handling', () => { + let triggerError = null; + + const Parent = () => { + const [shouldError, setShouldError] = React.useState(false); + triggerError = () => setShouldError(true); + return shouldError ? ( + + + + ) : ( + + + + ); + }; + class ErrorBoundary extends React.Component { + state = {error: null}; + componentDidCatch(error) { + this.setState({error}); + } + render() { + if (this.state.error) { + return ; + } + return this.props.children; + } + } + const Yield = ({value}) => { + Scheduler.yieldValue(value); + return null; + }; + const BrokenRender = () => { + throw new Error('Hello'); + }; + + const root = ReactDOM.unstable_createRoot(document.createElement('div')); + TestUtils.act(() => { + root.render(); + expect(Scheduler).toFlushAndYieldThrough(['initial', 'onCommitRoot']); + }); + expect(triggerError).not.toBeNull(); + + const schedulerTypes = []; + + mockDevToolsHook.onCommitRoot.mockImplementation(fiberRoot => { + Scheduler.yieldValue('onCommitRoot'); + schedulerTypes.push( + Array.from(fiberRoot.memoizedUpdaters).map(fiber => fiber.elementType), + ); + }); + + TestUtils.act(() => { + triggerError(); + expect(Scheduler).toFlushAndYieldThrough([ + 'onCommitRoot', + 'error', + 'onCommitRoot', + ]); + }); + expect(schedulerTypes).toHaveLength(2); + expect(schedulerTypes[0]).toHaveLength(1); + expect(schedulerTypes[0]).toContain(Parent); + expect(schedulerTypes[1]).toHaveLength(1); + expect(schedulerTypes[1]).toContain(ErrorBoundary); + + // Verify no outstanding flushes + Scheduler.flushAll(); + }); + + it('should distinguish between updaters in the case of interleaved work', () => { + let triggerLowPriorityUpdate = null; + let triggerSyncPriorityUpdate = null; + + const HighPriorityUpdater = () => { + const [count, setCount] = React.useState(0); + triggerSyncPriorityUpdate = () => setCount(prevCount => prevCount + 1); + Scheduler.yieldValue(`HighPriorityUpdater ${count}`); + return ; + }; + const LowPriorityUpdater = () => { + const [count, setCount] = React.useState(0); + triggerLowPriorityUpdate = () => setCount(prevCount => prevCount + 1); + Scheduler.yieldValue(`LowPriorityUpdater ${count}`); + return ; + }; + const Yield = ({value}) => { + Scheduler.yieldValue(`Yield ${value}`); + return null; + }; + + const root = ReactDOM.unstable_createRoot(document.createElement('div')); + TestUtils.act(() => { + root.render( + + + + , + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'HighPriorityUpdater 0', + 'Yield HighPriority 0', + 'LowPriorityUpdater 0', + 'Yield LowPriority 0', + 'onCommitRoot', + ]); + }); + expect(triggerLowPriorityUpdate).not.toBeNull(); + expect(triggerSyncPriorityUpdate).not.toBeNull(); + expect(allSchedulerTypes).toHaveLength(1); + + // Render a partially update, but don't finish. + TestUtils.act(() => { + triggerLowPriorityUpdate(); + expect(Scheduler).toFlushAndYieldThrough(['LowPriorityUpdater 1']); + expect(allSchedulerTypes).toHaveLength(1); + + // Interrupt with higher priority work. + ReactDOM.flushSync(triggerSyncPriorityUpdate); + expect(Scheduler).toHaveYielded([ + 'HighPriorityUpdater 1', + 'Yield HighPriority 1', + 'onCommitRoot', + ]); + expect(allSchedulerTypes).toHaveLength(2); + expect(allSchedulerTypes[1]).toHaveLength(1); + expect(allSchedulerTypes[1]).toContain(HighPriorityUpdater); + + // Finish the initial partial update + triggerLowPriorityUpdate(); + expect(Scheduler).toFlushAndYieldThrough([ + 'LowPriorityUpdater 2', + 'Yield LowPriority 2', + 'onCommitRoot', + ]); + }); + expect(allSchedulerTypes).toHaveLength(3); + expect(allSchedulerTypes[2]).toHaveLength(1); + expect(allSchedulerTypes[2]).toContain(LowPriorityUpdater); + + // Verify no outstanding flushes + Scheduler.flushAll(); + }); +}); diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index 74b62b9821e9a..fe623d3a969f2 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -31,6 +31,9 @@ export const enableProfilerTimer = __PROFILE__; // Trace which interactions trigger each commit. export const enableSchedulerTracing = __PROFILE__; +// Track which Fiber(s) schedule render work. +export const enableUpdaterTracking = __PROFILE__; + // Only used in www builds. export const enableSuspenseServerRenderer = false; // TODO: __DEV__? Here it might just be false. diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index 8b899da6d62e5..5f9f71a43090d 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -19,6 +19,7 @@ export const {debugRenderPhaseSideEffects} = require('ReactFeatureFlags'); export const enableUserTimingAPI = __DEV__; export const enableProfilerTimer = __PROFILE__; export const enableSchedulerTracing = __PROFILE__; +export const enableUpdaterTracking = __PROFILE__; export const enableSuspenseServerRenderer = false; export const enableStableConcurrentModeAPIs = false; export const warnAboutShorthandPropertyCollision = false; diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index c46a800a3a629..2d5395c6feabc 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -19,6 +19,7 @@ export const replayFailedUnitOfWorkWithInvokeGuardedCallback = __DEV__; export const warnAboutDeprecatedLifecycles = true; export const enableProfilerTimer = __PROFILE__; export const enableSchedulerTracing = __PROFILE__; +export const enableUpdaterTracking = __PROFILE__; export const enableSuspenseServerRenderer = false; export const disableJavaScriptURLs = false; export const disableYielding = false; diff --git a/packages/shared/forks/ReactFeatureFlags.persistent.js b/packages/shared/forks/ReactFeatureFlags.persistent.js index 511a10fa10e14..e3610747d3012 100644 --- a/packages/shared/forks/ReactFeatureFlags.persistent.js +++ b/packages/shared/forks/ReactFeatureFlags.persistent.js @@ -19,6 +19,7 @@ export const warnAboutDeprecatedLifecycles = true; export const replayFailedUnitOfWorkWithInvokeGuardedCallback = __DEV__; export const enableProfilerTimer = __PROFILE__; export const enableSchedulerTracing = __PROFILE__; +export const enableUpdaterTracking = __PROFILE__; export const enableSuspenseServerRenderer = false; export const disableJavaScriptURLs = false; export const disableYielding = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index aa5f144f6cee5..dc66b6df20848 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -19,6 +19,7 @@ export const warnAboutDeprecatedLifecycles = false; export const replayFailedUnitOfWorkWithInvokeGuardedCallback = false; export const enableProfilerTimer = false; export const enableSchedulerTracing = false; +export const enableUpdaterTracking = false; export const enableSuspenseServerRenderer = false; export const disableJavaScriptURLs = false; export const disableYielding = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index 1e215516a263e..4f8e30c2b367b 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -22,6 +22,7 @@ export const warnAboutDeprecatedLifecycles = true; export const replayFailedUnitOfWorkWithInvokeGuardedCallback = false; export const enableProfilerTimer = false; export const enableSchedulerTracing = false; +export const enableUpdaterTracking = false; export const enableSuspenseServerRenderer = false; export const enableStableConcurrentModeAPIs = false; export const enableSchedulerDebugging = false; diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index 40ca79e12865d..0d18738d40446 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -33,6 +33,7 @@ export let enableUserTimingAPI = __DEV__; export const enableProfilerTimer = __PROFILE__; export const enableSchedulerTracing = __PROFILE__; +export const enableUpdaterTracking = __PROFILE__; export const enableSchedulerDebugging = true; export const enableStableConcurrentModeAPIs = false;