@samitouri / QOS-React / commits / d83601080a

Wrap retrySuspendedRoot using SchedulerTracing (#13776)

Previously, we were emptying root.pendingInteractionMap and permanently losing those interactions when applying an unrelated update to a tree that has no scheduled work that is waiting on promise resolution. (That is, one that is showing a fallback and waiting for the suspended content to resolve.) The logic I'm leaving untouched with `nextRenderIncludesTimedOutPlaceholder` is *not* correct -- what we want is instead to know if *any* placeholder anywhere in the tree is showing its fallback -- but we don't currently have a better replacement, and this should unblock tracing with suspense again.

Sophie Alpert committed Oct 4, 2018 at 15:11 UTC d83601080a7c913cedcfbad86044702d008039c7
4 files changed +122 -129
packages/react-reconciler/src/ReactFiberBeginWork.js
-9
@@ -43,14 +43,12 @@ import {
43 Update,
44 Ref,
45 } from 'shared/ReactSideEffectTags';
46 -import {captureWillSyncRenderPlaceholder} from './ReactFiberScheduler';
46 import ReactSharedInternals from 'shared/ReactSharedInternals';
47 import {
48 enableSuspense,
49 debugRenderPhaseSideEffects,
50 debugRenderPhaseSideEffectsForStrictMode,
51 enableProfilerTimer,
53 - enableSchedulerTracing,
52 } from 'shared/ReactFeatureFlags';
53 import invariant from 'shared/invariant';
54 import shallowEqual from 'shared/shallowEqual';
@@ -952,13 +950,6 @@ function updatePlaceholderComponent(
950
951 let nextDidTimeout;
952 if (current !== null && workInProgress.updateQueue !== null) {
955 - if (enableSchedulerTracing) {
956 - // Handle special case of rendering a Placeholder for a sync, suspended tree.
957 - // We flag this to properly trace and count interactions.
958 - // Otherwise interaction pending count will be decremented too many times.
959 - captureWillSyncRenderPlaceholder();
960 - }
961 -
953 // We're outside strict mode. Something inside this Placeholder boundary
954 // suspended during the last commit. Switch to the placholder.
955 workInProgress.updateQueue = null;
packages/react-reconciler/src/ReactFiberScheduler.js
+81 -96
@@ -257,11 +257,6 @@ let legacyErrorBoundariesThatAlreadyFailed: Set<mixed> | null = null;
257 // Used for performance tracking.
258 let interruptedBy: Fiber | null = null;
259
260 -// Do not decrement interaction counts in the event of suspense timeouts.
261 -// This would lead to prematurely calling the interaction-complete hook.
262 -// Instead we wait until the suspended promise has resolved.
263 -let nextRenderIncludesTimedOutPlaceholder: boolean = false;
264 -
260 let stashedWorkInProgressProperties;
261 let replayUnitOfWork;
262 let isReplayingFailedUnitOfWork;
@@ -766,42 +761,40 @@ function commitRoot(root: FiberRoot, finishedWork: Fiber): void {
761 unhandledError = error;
762 }
763 } finally {
769 - if (!nextRenderIncludesTimedOutPlaceholder) {
770 - // Clear completed interactions from the pending Map.
771 - // Unless the render was suspended or cascading work was scheduled,
772 - // In which case– leave pending interactions until the subsequent render.
773 - const pendingInteractionMap = root.pendingInteractionMap;
774 - pendingInteractionMap.forEach(
775 - (scheduledInteractions, scheduledExpirationTime) => {
776 - // Only decrement the pending interaction count if we're done.
777 - // If there's still work at the current priority,
778 - // That indicates that we are waiting for suspense data.
779 - if (
780 - earliestRemainingTimeAfterCommit === NoWork ||
781 - scheduledExpirationTime < earliestRemainingTimeAfterCommit
782 - ) {
783 - pendingInteractionMap.delete(scheduledExpirationTime);
784 -
785 - scheduledInteractions.forEach(interaction => {
786 - interaction.__count--;
787 -
788 - if (subscriber !== null && interaction.__count === 0) {
789 - try {
790 - subscriber.onInteractionScheduledWorkCompleted(interaction);
791 - } catch (error) {
792 - // It's not safe for commitRoot() to throw.
793 - // Store the error for now and we'll re-throw in finishRendering().
794 - if (!hasUnhandledError) {
795 - hasUnhandledError = true;
796 - unhandledError = error;
797 - }
764 + // Clear completed interactions from the pending Map.
765 + // Unless the render was suspended or cascading work was scheduled,
766 + // In which case– leave pending interactions until the subsequent render.
767 + const pendingInteractionMap = root.pendingInteractionMap;
768 + pendingInteractionMap.forEach(
769 + (scheduledInteractions, scheduledExpirationTime) => {
770 + // Only decrement the pending interaction count if we're done.
771 + // If there's still work at the current priority,
772 + // That indicates that we are waiting for suspense data.
773 + if (
774 + earliestRemainingTimeAfterCommit === NoWork ||
775 + scheduledExpirationTime < earliestRemainingTimeAfterCommit
776 + ) {
777 + pendingInteractionMap.delete(scheduledExpirationTime);
778 +
779 + scheduledInteractions.forEach(interaction => {
780 + interaction.__count--;
781 +
782 + if (subscriber !== null && interaction.__count === 0) {
783 + try {
784 + subscriber.onInteractionScheduledWorkCompleted(interaction);
785 + } catch (error) {
786 + // It's not safe for commitRoot() to throw.
787 + // Store the error for now and we'll re-throw in finishRendering().
788 + if (!hasUnhandledError) {
789 + hasUnhandledError = true;
790 + unhandledError = error;
791 }
792 }
800 - });
801 - }
802 - },
803 - );
804 - }
793 + }
794 + });
795 + }
796 + },
797 + );
798 }
799 }
800 }
@@ -1173,11 +1166,6 @@ function renderRoot(
1166 root.pendingCommitExpirationTime = NoWork;
1167
1168 if (enableSchedulerTracing) {
1176 - // Reset this flag once we start rendering a new root or at a new priority.
1177 - // This might indicate that suspended work has completed.
1178 - // If not, the flag will be reset.
1179 - nextRenderIncludesTimedOutPlaceholder = false;
1180 -
1169 // Determine which interactions this batch of work currently includes,
1170 // So that we can accurately attribute time spent working on it,
1171 // And so that cascading work triggered during the render phase will be associated with it.
@@ -1388,9 +1376,6 @@ function renderRoot(
1376
1377 if (enableSuspense && !isExpired && nextLatestAbsoluteTimeoutMs !== -1) {
1378 // The tree was suspended.
1391 - if (enableSchedulerTracing) {
1392 - nextRenderIncludesTimedOutPlaceholder = true;
1393 - }
1379 const suspendedExpirationTime = expirationTime;
1380 markSuspendedPriorityLevel(root, suspendedExpirationTime);
1381
@@ -1597,6 +1582,13 @@ function retrySuspendedRoot(
1582 markPendingPriorityLevel(root, retryTime);
1583 }
1584
1585 + // TODO: If the placeholder fiber has already rendered the primary children
1586 + // without suspending (that is, all of the promises have already resolved),
1587 + // we should not trigger another update here. One case this happens is when
1588 + // we are in sync mode and a single promise is thrown both on initial render
1589 + // and on update; we attach two .then(retrySuspendedRoot) callbacks and each
1590 + // one performs Sync work, rerendering the Placeholder.
1591 +
1592 if ((fiber.mode & ConcurrentMode) !== NoContext) {
1593 if (root === nextRoot && nextRenderExpirationTime === suspendedTime) {
1594 // Received a ping at the same priority level at which we're currently
@@ -1614,6 +1606,15 @@ function retrySuspendedRoot(
1606 }
1607
1608 function scheduleWorkToRoot(fiber: Fiber, expirationTime): FiberRoot | null {
1609 + recordScheduleUpdate();
1610 +
1611 + if (__DEV__) {
1612 + if (fiber.tag === ClassComponent || fiber.tag === ClassComponentLazy) {
1613 + const instance = fiber.stateNode;
1614 + warnAboutInvalidUpdates(instance);
1615 + }
1616 + }
1617 +
1618 // Update the source fiber's expiration time
1619 if (
1620 fiber.expirationTime === NoWork ||
@@ -1631,49 +1632,39 @@ function scheduleWorkToRoot(fiber: Fiber, expirationTime): FiberRoot | null {
1632 }
1633 // Walk the parent path to the root and update the child expiration time.
1634 let node = fiber.return;
1635 + let root = null;
1636 if (node === null && fiber.tag === HostRoot) {
1635 - return fiber.stateNode;
1636 - }
1637 - while (node !== null) {
1638 - alternate = node.alternate;
1639 - if (
1640 - node.childExpirationTime === NoWork ||
1641 - node.childExpirationTime > expirationTime
1642 - ) {
1643 - node.childExpirationTime = expirationTime;
1637 + root = fiber.stateNode;
1638 + } else {
1639 + while (node !== null) {
1640 + alternate = node.alternate;
1641 if (
1642 + node.childExpirationTime === NoWork ||
1643 + node.childExpirationTime > expirationTime
1644 + ) {
1645 + node.childExpirationTime = expirationTime;
1646 + if (
1647 + alternate !== null &&
1648 + (alternate.childExpirationTime === NoWork ||
1649 + alternate.childExpirationTime > expirationTime)
1650 + ) {
1651 + alternate.childExpirationTime = expirationTime;
1652 + }
1653 + } else if (
1654 alternate !== null &&
1655 (alternate.childExpirationTime === NoWork ||
1656 alternate.childExpirationTime > expirationTime)
1657 ) {
1658 alternate.childExpirationTime = expirationTime;
1659 }
1651 - } else if (
1652 - alternate !== null &&
1653 - (alternate.childExpirationTime === NoWork ||
1654 - alternate.childExpirationTime > expirationTime)
1655 - ) {
1656 - alternate.childExpirationTime = expirationTime;
1657 - }
1658 - if (node.return === null && node.tag === HostRoot) {
1659 - return node.stateNode;
1660 - }
1661 - node = node.return;
1662 - }
1663 - return null;
1664 -}
1665 -
1666 -function scheduleWork(fiber: Fiber, expirationTime: ExpirationTime) {
1667 - recordScheduleUpdate();
1668 -
1669 - if (__DEV__) {
1670 - if (fiber.tag === ClassComponent || fiber.tag === ClassComponentLazy) {
1671 - const instance = fiber.stateNode;
1672 - warnAboutInvalidUpdates(instance);
1660 + if (node.return === null && node.tag === HostRoot) {
1661 + root = node.stateNode;
1662 + break;
1663 + }
1664 + node = node.return;
1665 }
1666 }
1667
1676 - const root = scheduleWorkToRoot(fiber, expirationTime);
1668 if (root === null) {
1669 if (
1670 __DEV__ &&
@@ -1681,7 +1672,7 @@ function scheduleWork(fiber: Fiber, expirationTime: ExpirationTime) {
1672 ) {
1673 warnAboutUpdateOnUnmounted(fiber);
1674 }
1684 - return;
1675 + return null;
1676 }
1677
1678 if (enableSchedulerTracing) {
@@ -1718,6 +1709,15 @@ function scheduleWork(fiber: Fiber, expirationTime: ExpirationTime) {
1709 }
1710 }
1711
1712 + return root;
1713 +}
1714 +
1715 +function scheduleWork(fiber: Fiber, expirationTime: ExpirationTime) {
1716 + const root = scheduleWorkToRoot(fiber, expirationTime);
1717 + if (root === null) {
1718 + return;
1719 + }
1720 +
1721 if (
1722 !isWorking &&
1723 nextRenderExpirationTime !== NoWork &&
@@ -1904,15 +1904,7 @@ function onTimeout(root, finishedWork, suspendedExpirationTime) {
1904 // because we're at the top of a timer event.
1905 recomputeCurrentRendererTime();
1906 currentSchedulerTime = currentRendererTime;
1907 -
1908 - if (enableSchedulerTracing) {
1909 - // Don't update pending interaction counts for suspense timeouts,
1910 - // Because we know we still need to do more work in this case.
1911 - nextRenderIncludesTimedOutPlaceholder = true;
1912 - flushRoot(root, suspendedExpirationTime);
1913 - } else {
1914 - flushRoot(root, suspendedExpirationTime);
1915 - }
1907 + flushRoot(root, suspendedExpirationTime);
1908 }
1909 }
1910
@@ -2480,12 +2472,6 @@ function flushControlled(fn: () => mixed): void {
2472 }
2473 }
2474
2483 -function captureWillSyncRenderPlaceholder() {
2484 - if (enableSchedulerTracing) {
2485 - nextRenderIncludesTimedOutPlaceholder = true;
2486 - }
2487 -}
2488 -
2475 export {
2476 requestCurrentTime,
2477 computeExpirationForFiber,
@@ -2508,5 +2494,4 @@ export {
2494 interactiveUpdates,
2495 flushInteractiveUpdates,
2496 computeUniqueAsyncExpiration,
2511 - captureWillSyncRenderPlaceholder,
2497 };
packages/react-reconciler/src/ReactFiberUnwindWork.js
+5 -9
@@ -14,6 +14,7 @@ import type {CapturedValue} from './ReactCapturedValue';
14 import type {Update} from './ReactUpdateQueue';
15 import type {Thenable} from './ReactFiberScheduler';
16
17 +import {unstable_wrap as Schedule_tracing_wrap} from 'scheduler/tracing';
18 import getComponentName from 'shared/getComponentName';
19 import warningWithoutStack from 'shared/warningWithoutStack';
20 import {
@@ -59,7 +60,6 @@ import {
60 markLegacyErrorBoundaryAsFailed,
61 isAlreadyFailedLegacyErrorBoundary,
62 retrySuspendedRoot,
62 - captureWillSyncRenderPlaceholder,
63 } from './ReactFiberScheduler';
64 import {Sync} from './ReactFiberExpirationTime';
65
@@ -224,12 +224,15 @@ function throwException(
224 : renderExpirationTime;
225
226 // Attach a listener to the promise to "ping" the root and retry.
227 - const onResolveOrReject = retrySuspendedRoot.bind(
227 + let onResolveOrReject = retrySuspendedRoot.bind(
228 null,
229 root,
230 workInProgress,
231 pingTime,
232 );
233 + if (enableSchedulerTracing) {
234 + onResolveOrReject = Schedule_tracing_wrap(onResolveOrReject);
235 + }
236 thenable.then(onResolveOrReject, onResolveOrReject);
237
238 // If the boundary is outside of strict mode, we should *not* suspend
@@ -243,13 +246,6 @@ function throwException(
246 if ((workInProgress.mode & StrictMode) === NoEffect) {
247 workInProgress.effectTag |= UpdateEffect;
248
246 - if (enableSchedulerTracing) {
247 - // Handles the special case of unwinding a suspended sync render.
248 - // We flag this to properly trace and count interactions.
249 - // Otherwise interaction pending count will be decremented too many times.
250 - captureWillSyncRenderPlaceholder();
251 - }
252 -
249 // Unmount the source fiber's children
250 const nextChildren = null;
251 reconcileChildren(
packages/react/src/__tests__/ReactProfiler-test.internal.js
+36 -15
@@ -2219,6 +2219,14 @@ describe('Profiler', () => {
2219 timestamp: mockNow(),
2220 };
2221
2222 + const monkey = React.createRef();
2223 + class Monkey extends React.Component {
2224 + render() {
2225 + ReactNoop.yield('Monkey');
2226 + return null;
2227 + }
2228 + }
2229 +
2230 const onRender = jest.fn();
2231 SchedulerTracing.unstable_trace(interaction.name, mockNow(), () => {
2232 ReactNoop.render(
@@ -2227,6 +2235,7 @@ describe('Profiler', () => {
2235 <AsyncText text="Async" ms={20000} />
2236 </React.Placeholder>
2237 <Text text="Sync" />
2238 + <Monkey ref={monkey} />
2239 </React.unstable_Profiler>,
2240 );
2241 });
@@ -2243,6 +2252,7 @@ describe('Profiler', () => {
2252 'Suspend [Async]',
2253 'Text [Loading...]',
2254 'Text [Sync]',
2255 + 'Monkey',
2256 ]);
2257 // The update hasn't expired yet, so we commit nothing.
2258 expect(ReactNoop.getChildren()).toEqual([]);
@@ -2271,6 +2281,11 @@ describe('Profiler', () => {
2281 expect(onInteractionTraced).toHaveBeenCalledTimes(1);
2282 expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled();
2283
2284 + // An unrelated update in the middle shouldn't affect things...
2285 + monkey.current.forceUpdate();
2286 + expect(ReactNoop.flush()).toEqual(['Monkey']);
2287 + expect(onRender).toHaveBeenCalledTimes(2);
2288 +
2289 // Once the promise resolves, we render the suspended view
2290 await awaitableAdvanceTimers(10000);
2291 expect(ReactNoop.flush()).toEqual([
@@ -2281,9 +2296,9 @@ describe('Profiler', () => {
2296 {text: 'Async'},
2297 {text: 'Sync'},
2298 ]);
2284 - expect(onRender).toHaveBeenCalledTimes(2);
2299 + expect(onRender).toHaveBeenCalledTimes(3);
2300
2286 - call = onRender.mock.calls[1];
2301 + call = onRender.mock.calls[2];
2302 expect(call[0]).toEqual('test-profiler');
2303 expect(call[6]).toMatchInteractions(
2304 ReactFeatureFlags.enableSchedulerTracing ? [interaction] : [],
@@ -2540,7 +2555,9 @@ describe('Profiler', () => {
2555
2556 expect(onRender).toHaveBeenCalledTimes(2); // Sync null commit, placeholder commit
2557 expect(onRender.mock.calls[0][6]).toMatchInteractions([
2543 - initialRenderInteraction,
2558 + highPriUpdateInteraction,
2559 + ]);
2560 + expect(onRender.mock.calls[1][6]).toMatchInteractions([
2561 highPriUpdateInteraction,
2562 ]);
2563 onRender.mockClear();
@@ -2552,9 +2569,12 @@ describe('Profiler', () => {
2569 await originalPromise;
2570 expect(renderer.toJSON()).toEqual(['loaded', 'updated']);
2571
2572 + // TODO: Bug. This *should* just be one render tied to both interactions.
2573 expect(onRender).toHaveBeenCalledTimes(2);
2574 expect(onRender.mock.calls[0][6]).toMatchInteractions([
2575 initialRenderInteraction,
2576 + ]);
2577 + expect(onRender.mock.calls[1][6]).toMatchInteractions([
2578 highPriUpdateInteraction,
2579 ]);
2580
@@ -2631,6 +2651,11 @@ describe('Profiler', () => {
2651 },
2652 );
2653 });
2654 + expect(ReactTestRenderer).toHaveYielded([
2655 + 'Suspend [loaded]',
2656 + 'Text [loading]',
2657 + 'Text [updated]',
2658 + ]);
2659 expect(renderer.toJSON()).toEqual(['loading', 'updated']);
2660
2661 expect(onRender).toHaveBeenCalledTimes(1);
@@ -2639,32 +2664,28 @@ describe('Profiler', () => {
2664 ]);
2665 onRender.mockClear();
2666
2642 - expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(1);
2643 - expect(
2644 - onInteractionScheduledWorkCompleted,
2645 - ).toHaveBeenLastNotifiedOfInteraction(highPriUpdateInteraction);
2667 + expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(0);
2668
2669 advanceTimeBy(500);
2670 jest.advanceTimersByTime(500);
2671 await originalPromise;
2650 - expect(ReactTestRenderer).toHaveYielded([
2651 - 'Suspend [loaded]',
2652 - 'Text [loading]',
2653 - 'Text [updated]',
2654 - 'Promise resolved [loaded]',
2655 - ]);
2672 + expect(ReactTestRenderer).toHaveYielded(['Promise resolved [loaded]']);
2673 expect(renderer).toFlushAndYield(['AsyncText [loaded]']);
2674 expect(renderer.toJSON()).toEqual(['loaded', 'updated']);
2675
2676 expect(onRender).toHaveBeenCalledTimes(1);
2677 expect(onRender.mock.calls[0][6]).toMatchInteractions([
2678 initialRenderInteraction,
2679 + highPriUpdateInteraction,
2680 ]);
2681
2682 expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(2);
2683 expect(
2666 - onInteractionScheduledWorkCompleted,
2667 - ).toHaveBeenLastNotifiedOfInteraction(initialRenderInteraction);
2684 + onInteractionScheduledWorkCompleted.mock.calls[0][0],
2685 + ).toMatchInteraction(initialRenderInteraction);
2686 + expect(
2687 + onInteractionScheduledWorkCompleted.mock.calls[1][0],
2688 + ).toMatchInteraction(highPriUpdateInteraction);
2689 });
2690 });
2691 });