@samitouri / QOS-React-2 / commits / 013b7ad117

[suspense][error handling] Inline renderRoot and fix error handling bug (#16801)

* Outline push/pop logic in `renderRoot` I want to get rid of the the `isSync` argument to `renderRoot`, and instead use separate functions for concurrent and synchronous render. As a first step, this extracts the push/pop logic that happens before and after the render phase into helper functions. * Extract `catch` block into helper function Similar to previous commit. Extract error handling logic into a separate function so it can be reused. * Fork `renderRoot` for sync and concurrent Removes `isSync` argument in favor of separate functions. * Extra "root completion" logic to separate function Moving this out to avoid an accidental early return, which would bypass the call to `ensureRootIsScheduled` and freeze the UI. * Inline `renderRoot` Inlines `renderRoot` into `performConcurrentWorkOnRoot` and `performSyncWorkOnRoot`. This lets me remove the `isSync` argument and also get rid of a redundant try-catch wrapper. * [suspense][error handling] Add failing unit test Covers an edge case where an error is thrown inside the complete phase of a component that is in the return path of a component that suspends. The second error should also be handled (i.e. able to be captured by an error boundary. The test is currently failing because there's a call to `completeUnitOfWork` inside the main render phase `catch` block. That call is not itself wrapped in try-catch, so anything that throws is treated as a fatal/unhandled error. I believe this bug is only observable if something in the host config throws; and, only in legacy mode, because in concurrent/batched mode, `completeUnitOfWork` on fiber that throws follows the "unwind" path only, not the "complete" path, and the "unwind" path does not call any host config methods. * [scheduler][profiler] Start time of delayed tasks Fixes a bug in the Scheduler profiler where the start time of a delayed tasks is always 0. * Remove ad hoc `throw` Fatal errors (errors that are not captured by an error boundary) are currently rethrown from directly inside the render phase's `catch` block. This is a refactor hazard because the code in this branch has to mirror the code that happens at the end of the function, when exiting the render phase in the normal case. This commit moves the throw to the end, using a new root exit status. * Handle errors that occur on unwind

Andrew Clark committed Sep 23, 2019 at 11:23 UTC 013b7ad117834cbb99b4fc0a3d08fdb8622597c9
3 files changed +504 -404
packages/react-reconciler/src/ReactFiberWorkLoop.js
+468 -403
@@ -200,13 +200,14 @@ const LegacyUnbatchedContext = /* */ 0b001000;
200 const RenderContext = /* */ 0b010000;
201 const CommitContext = /* */ 0b100000;
202
203 -type RootExitStatus = 0 | 1 | 2 | 3 | 4 | 5;
203 +type RootExitStatus = 0 | 1 | 2 | 3 | 4 | 5 | 6;
204 const RootIncomplete = 0;
205 -const RootErrored = 1;
206 -const RootSuspended = 2;
207 -const RootSuspendedWithDelay = 3;
208 -const RootCompleted = 4;
209 -const RootLocked = 5;
205 +const RootFatalErrored = 1;
206 +const RootErrored = 2;
207 +const RootSuspended = 3;
208 +const RootSuspendedWithDelay = 4;
209 +const RootCompleted = 5;
210 +const RootLocked = 6;
211
212 export type Thenable = {
213 then(resolve: () => mixed, reject?: () => mixed): Thenable | void,
@@ -225,6 +226,8 @@ let workInProgress: Fiber | null = null;
226 let renderExpirationTime: ExpirationTime = NoWork;
227 // Whether to root completed, errored, suspended, etc.
228 let workInProgressRootExitStatus: RootExitStatus = RootIncomplete;
229 +// A fatal error, if one is thrown
230 +let workInProgressRootFatalError: mixed = null;
231 // Most recent event time among processed updates during this render.
232 // This is conceptually a time stamp but expressed in terms of an ExpirationTime
233 // because we deal mostly with expiration times in the hot path, so this avoids
@@ -655,8 +658,55 @@ function performConcurrentWorkOnRoot(root, didTimeout) {
658 const expirationTime = getNextRootExpirationTimeToWorkOn(root);
659 if (expirationTime !== NoWork) {
660 const originalCallbackNode = root.callbackNode;
658 - try {
659 - renderRoot(root, expirationTime, false);
661 + invariant(
662 + (executionContext & (RenderContext | CommitContext)) === NoContext,
663 + 'Should not already be working.',
664 + );
665 +
666 + flushPassiveEffects();
667 +
668 + // If the root or expiration time have changed, throw out the existing stack
669 + // and prepare a fresh one. Otherwise we'll continue where we left off.
670 + if (
671 + root !== workInProgressRoot ||
672 + expirationTime !== renderExpirationTime
673 + ) {
674 + prepareFreshStack(root, expirationTime);
675 + startWorkOnPendingInteractions(root, expirationTime);
676 + }
677 +
678 + // If we have a work-in-progress fiber, it means there's still work to do
679 + // in this root.
680 + if (workInProgress !== null) {
681 + const prevExecutionContext = executionContext;
682 + executionContext |= RenderContext;
683 + const prevDispatcher = pushDispatcher(root);
684 + const prevInteractions = pushInteractions(root);
685 + startWorkLoopTimer(workInProgress);
686 + do {
687 + try {
688 + workLoopConcurrent();
689 + break;
690 + } catch (thrownValue) {
691 + handleError(root, thrownValue);
692 + }
693 + } while (true);
694 + resetContextDependencies();
695 + executionContext = prevExecutionContext;
696 + popDispatcher(prevDispatcher);
697 + if (enableSchedulerTracing) {
698 + popInteractions(((prevInteractions: any): Set<Interaction>));
699 + }
700 +
701 + if (workInProgressRootExitStatus === RootFatalErrored) {
702 + const fatalError = workInProgressRootFatalError;
703 + stopInterruptedWorkLoopTimer();
704 + prepareFreshStack(root, expirationTime);
705 + markRootSuspendedAtTime(root, expirationTime);
706 + ensureRootIsScheduled(root);
707 + throw fatalError;
708 + }
709 +
710 if (workInProgress !== null) {
711 // There's still work left over. Exit without committing.
712 stopInterruptedWorkLoopTimer();
@@ -668,281 +718,274 @@ function performConcurrentWorkOnRoot(root, didTimeout) {
718 const finishedWork: Fiber = ((root.finishedWork =
719 root.current.alternate): any);
720 root.finishedExpirationTime = expirationTime;
671 -
721 resolveLocksOnRoot(root, expirationTime);
722 + finishConcurrentRender(
723 + root,
724 + finishedWork,
725 + workInProgressRootExitStatus,
726 + expirationTime,
727 + );
728 + }
729 +
730 + ensureRootIsScheduled(root);
731 + if (root.callbackNode === originalCallbackNode) {
732 + // The task node scheduled for this root is the same one that's
733 + // currently executed. Need to return a continuation.
734 + return performConcurrentWorkOnRoot.bind(null, root);
735 + }
736 + }
737 + }
738 + return null;
739 +}
740
674 - // Set this to null to indicate there's no in-progress render.
675 - workInProgressRoot = null;
741 +function finishConcurrentRender(
742 + root,
743 + finishedWork,
744 + exitStatus,
745 + expirationTime,
746 +) {
747 + // Set this to null to indicate there's no in-progress render.
748 + workInProgressRoot = null;
749
677 - switch (workInProgressRootExitStatus) {
678 - case RootIncomplete: {
679 - invariant(false, 'Should have a work-in-progress.');
680 - }
681 - // Flow knows about invariant, so it complains if I add a break
682 - // statement, but eslint doesn't know about invariant, so it complains
683 - // if I do. eslint-disable-next-line no-fallthrough
684 - case RootErrored: {
685 - if (expirationTime !== Idle) {
686 - // If this was an async render, the error may have happened due to
687 - // a mutation in a concurrent event. Try rendering one more time,
688 - // synchronously, to see if the error goes away. If there are
689 - // lower priority updates, let's include those, too, in case they
690 - // fix the inconsistency. Render at Idle to include all updates.
691 - markRootExpiredAtTime(root, Idle);
750 + switch (exitStatus) {
751 + case RootIncomplete:
752 + case RootFatalErrored: {
753 + invariant(false, 'Root did not complete. This is a bug in React.');
754 + }
755 + // Flow knows about invariant, so it complains if I add a break
756 + // statement, but eslint doesn't know about invariant, so it complains
757 + // if I do. eslint-disable-next-line no-fallthrough
758 + case RootErrored: {
759 + if (expirationTime !== Idle) {
760 + // If this was an async render, the error may have happened due to
761 + // a mutation in a concurrent event. Try rendering one more time,
762 + // synchronously, to see if the error goes away. If there are
763 + // lower priority updates, let's include those, too, in case they
764 + // fix the inconsistency. Render at Idle to include all updates.
765 + markRootExpiredAtTime(root, Idle);
766 + break;
767 + }
768 + // Commit the root in its errored state.
769 + commitRoot(root);
770 + break;
771 + }
772 + case RootSuspended: {
773 + markRootSuspendedAtTime(root, expirationTime);
774 + const lastSuspendedTime = root.lastSuspendedTime;
775 + if (expirationTime === lastSuspendedTime) {
776 + root.nextKnownPendingLevel = getRemainingExpirationTime(finishedWork);
777 + }
778 + flushSuspensePriorityWarningInDEV();
779 +
780 + // We have an acceptable loading state. We need to figure out if we
781 + // should immediately commit it or wait a bit.
782 +
783 + // If we have processed new updates during this render, we may now
784 + // have a new loading state ready. We want to ensure that we commit
785 + // that as soon as possible.
786 + const hasNotProcessedNewUpdates =
787 + workInProgressRootLatestProcessedExpirationTime === Sync;
788 + if (
789 + hasNotProcessedNewUpdates &&
790 + // do not delay if we're inside an act() scope
791 + !(
792 + __DEV__ &&
793 + flushSuspenseFallbacksInTests &&
794 + IsThisRendererActing.current
795 + )
796 + ) {
797 + // If we have not processed any new updates during this pass, then
798 + // this is either a retry of an existing fallback state or a
799 + // hidden tree. Hidden trees shouldn't be batched with other work
800 + // and after that's fixed it can only be a retry. We're going to
801 + // throttle committing retries so that we don't show too many
802 + // loading states too quickly.
803 + let msUntilTimeout =
804 + globalMostRecentFallbackTime + FALLBACK_THROTTLE_MS - now();
805 + // Don't bother with a very short suspense time.
806 + if (msUntilTimeout > 10) {
807 + if (workInProgressRootHasPendingPing) {
808 + const lastPingedTime = root.lastPingedTime;
809 + if (lastPingedTime === NoWork || lastPingedTime >= expirationTime) {
810 + // This render was pinged but we didn't get to restart
811 + // earlier so try restarting now instead.
812 + root.lastPingedTime = expirationTime;
813 + prepareFreshStack(root, expirationTime);
814 break;
815 }
694 - // Commit the root in its errored state.
695 - commitRoot(root);
816 + }
817 +
818 + const nextTime = getNextRootExpirationTimeToWorkOn(root);
819 + if (nextTime !== NoWork && nextTime !== expirationTime) {
820 + // There's additional work on this root.
821 + break;
822 + }
823 + if (
824 + lastSuspendedTime !== NoWork &&
825 + lastSuspendedTime !== expirationTime
826 + ) {
827 + // We should prefer to render the fallback of at the last
828 + // suspended level. Ping the last suspended level to try
829 + // rendering it again.
830 + root.lastPingedTime = lastSuspendedTime;
831 break;
832 }
698 - case RootSuspended: {
699 - markRootSuspendedAtTime(root, expirationTime);
700 - const lastSuspendedTime = root.lastSuspendedTime;
701 - if (expirationTime === lastSuspendedTime) {
702 - root.nextKnownPendingLevel = getRemainingExpirationTime(
703 - finishedWork,
704 - );
705 - }
706 - flushSuspensePriorityWarningInDEV();
707 -
708 - // We have an acceptable loading state. We need to figure out if we
709 - // should immediately commit it or wait a bit.
710 -
711 - // If we have processed new updates during this render, we may now
712 - // have a new loading state ready. We want to ensure that we commit
713 - // that as soon as possible.
714 - const hasNotProcessedNewUpdates =
715 - workInProgressRootLatestProcessedExpirationTime === Sync;
716 - if (
717 - hasNotProcessedNewUpdates &&
718 - // do not delay if we're inside an act() scope
719 - !(
720 - __DEV__ &&
721 - flushSuspenseFallbacksInTests &&
722 - IsThisRendererActing.current
723 - )
724 - ) {
725 - // If we have not processed any new updates during this pass, then
726 - // this is either a retry of an existing fallback state or a
727 - // hidden tree. Hidden trees shouldn't be batched with other work
728 - // and after that's fixed it can only be a retry. We're going to
729 - // throttle committing retries so that we don't show too many
730 - // loading states too quickly.
731 - let msUntilTimeout =
732 - globalMostRecentFallbackTime + FALLBACK_THROTTLE_MS - now();
733 - // Don't bother with a very short suspense time.
734 - if (msUntilTimeout > 10) {
735 - if (workInProgressRootHasPendingPing) {
736 - const lastPingedTime = root.lastPingedTime;
737 - if (
738 - lastPingedTime === NoWork ||
739 - lastPingedTime >= expirationTime
740 - ) {
741 - // This render was pinged but we didn't get to restart
742 - // earlier so try restarting now instead.
743 - root.lastPingedTime = expirationTime;
744 - prepareFreshStack(root, expirationTime);
745 - break;
746 - }
747 - }
833
749 - const nextTime = getNextRootExpirationTimeToWorkOn(root);
750 - if (nextTime !== NoWork && nextTime !== expirationTime) {
751 - // There's additional work on this root.
752 - break;
753 - }
754 - if (
755 - lastSuspendedTime !== NoWork &&
756 - lastSuspendedTime !== expirationTime
757 - ) {
758 - // We should prefer to render the fallback of at the last
759 - // suspended level. Ping the last suspended level to try
760 - // rendering it again.
761 - root.lastPingedTime = lastSuspendedTime;
762 - break;
763 - }
834 + // The render is suspended, it hasn't timed out, and there's no
835 + // lower priority work to do. Instead of committing the fallback
836 + // immediately, wait for more data to arrive.
837 + root.timeoutHandle = scheduleTimeout(
838 + commitRoot.bind(null, root),
839 + msUntilTimeout,
840 + );
841 + break;
842 + }
843 + }
844 + // The work expired. Commit immediately.
845 + commitRoot(root);
846 + break;
847 + }
848 + case RootSuspendedWithDelay: {
849 + markRootSuspendedAtTime(root, expirationTime);
850 + const lastSuspendedTime = root.lastSuspendedTime;
851 + if (expirationTime === lastSuspendedTime) {
852 + root.nextKnownPendingLevel = getRemainingExpirationTime(finishedWork);
853 + }
854 + flushSuspensePriorityWarningInDEV();
855
765 - // The render is suspended, it hasn't timed out, and there's no
766 - // lower priority work to do. Instead of committing the fallback
767 - // immediately, wait for more data to arrive.
768 - root.timeoutHandle = scheduleTimeout(
769 - commitRoot.bind(null, root),
770 - msUntilTimeout,
771 - );
772 - break;
773 - }
774 - }
775 - // The work expired. Commit immediately.
776 - commitRoot(root);
856 + if (
857 + // do not delay if we're inside an act() scope
858 + !(
859 + __DEV__ &&
860 + flushSuspenseFallbacksInTests &&
861 + IsThisRendererActing.current
862 + )
863 + ) {
864 + // We're suspended in a state that should be avoided. We'll try to
865 + // avoid committing it for as long as the timeouts let us.
866 + if (workInProgressRootHasPendingPing) {
867 + const lastPingedTime = root.lastPingedTime;
868 + if (lastPingedTime === NoWork || lastPingedTime >= expirationTime) {
869 + // This render was pinged but we didn't get to restart earlier
870 + // so try restarting now instead.
871 + root.lastPingedTime = expirationTime;
872 + prepareFreshStack(root, expirationTime);
873 break;
874 }
779 - case RootSuspendedWithDelay: {
780 - markRootSuspendedAtTime(root, expirationTime);
781 - const lastSuspendedTime = root.lastSuspendedTime;
782 - if (expirationTime === lastSuspendedTime) {
783 - root.nextKnownPendingLevel = getRemainingExpirationTime(
784 - finishedWork,
785 - );
786 - }
787 - flushSuspensePriorityWarningInDEV();
788 -
789 - if (
790 - // do not delay if we're inside an act() scope
791 - !(
792 - __DEV__ &&
793 - flushSuspenseFallbacksInTests &&
794 - IsThisRendererActing.current
795 - )
796 - ) {
797 - // We're suspended in a state that should be avoided. We'll try to
798 - // avoid committing it for as long as the timeouts let us.
799 - if (workInProgressRootHasPendingPing) {
800 - const lastPingedTime = root.lastPingedTime;
801 - if (
802 - lastPingedTime === NoWork ||
803 - lastPingedTime >= expirationTime
804 - ) {
805 - // This render was pinged but we didn't get to restart earlier
806 - // so try restarting now instead.
807 - root.lastPingedTime = expirationTime;
808 - prepareFreshStack(root, expirationTime);
809 - break;
810 - }
811 - }
812 -
813 - const nextTime = getNextRootExpirationTimeToWorkOn(root);
814 - if (nextTime !== NoWork && nextTime !== expirationTime) {
815 - // There's additional work on this root.
816 - break;
817 - }
818 - if (
819 - lastSuspendedTime !== NoWork &&
820 - lastSuspendedTime !== expirationTime
821 - ) {
822 - // We should prefer to render the fallback of at the last
823 - // suspended level. Ping the last suspended level to try
824 - // rendering it again.
825 - root.lastPingedTime = lastSuspendedTime;
826 - break;
827 - }
875 + }
876
829 - let msUntilTimeout;
830 - if (workInProgressRootLatestSuspenseTimeout !== Sync) {
831 - // We have processed a suspense config whose expiration time we
832 - // can use as the timeout.
833 - msUntilTimeout =
834 - expirationTimeToMs(workInProgressRootLatestSuspenseTimeout) -
835 - now();
836 - } else if (
837 - workInProgressRootLatestProcessedExpirationTime === Sync
838 - ) {
839 - // This should never normally happen because only new updates
840 - // cause delayed states, so we should have processed something.
841 - // However, this could also happen in an offscreen tree.
842 - msUntilTimeout = 0;
843 - } else {
844 - // If we don't have a suspense config, we're going to use a
845 - // heuristic to determine how long we can suspend.
846 - const eventTimeMs: number = inferTimeFromExpirationTime(
847 - workInProgressRootLatestProcessedExpirationTime,
848 - );
849 - const currentTimeMs = now();
850 - const timeUntilExpirationMs =
851 - expirationTimeToMs(expirationTime) - currentTimeMs;
852 - let timeElapsed = currentTimeMs - eventTimeMs;
853 - if (timeElapsed < 0) {
854 - // We get this wrong some time since we estimate the time.
855 - timeElapsed = 0;
856 - }
877 + const nextTime = getNextRootExpirationTimeToWorkOn(root);
878 + if (nextTime !== NoWork && nextTime !== expirationTime) {
879 + // There's additional work on this root.
880 + break;
881 + }
882 + if (
883 + lastSuspendedTime !== NoWork &&
884 + lastSuspendedTime !== expirationTime
885 + ) {
886 + // We should prefer to render the fallback of at the last
887 + // suspended level. Ping the last suspended level to try
888 + // rendering it again.
889 + root.lastPingedTime = lastSuspendedTime;
890 + break;
891 + }
892
858 - msUntilTimeout = jnd(timeElapsed) - timeElapsed;
893 + let msUntilTimeout;
894 + if (workInProgressRootLatestSuspenseTimeout !== Sync) {
895 + // We have processed a suspense config whose expiration time we
896 + // can use as the timeout.
897 + msUntilTimeout =
898 + expirationTimeToMs(workInProgressRootLatestSuspenseTimeout) - now();
899 + } else if (workInProgressRootLatestProcessedExpirationTime === Sync) {
900 + // This should never normally happen because only new updates
901 + // cause delayed states, so we should have processed something.
902 + // However, this could also happen in an offscreen tree.
903 + msUntilTimeout = 0;
904 + } else {
905 + // If we don't have a suspense config, we're going to use a
906 + // heuristic to determine how long we can suspend.
907 + const eventTimeMs: number = inferTimeFromExpirationTime(
908 + workInProgressRootLatestProcessedExpirationTime,
909 + );
910 + const currentTimeMs = now();
911 + const timeUntilExpirationMs =
912 + expirationTimeToMs(expirationTime) - currentTimeMs;
913 + let timeElapsed = currentTimeMs - eventTimeMs;
914 + if (timeElapsed < 0) {
915 + // We get this wrong some time since we estimate the time.
916 + timeElapsed = 0;
917 + }
918
860 - // Clamp the timeout to the expiration time. TODO: Once the
861 - // event time is exact instead of inferred from expiration time
862 - // we don't need this.
863 - if (timeUntilExpirationMs < msUntilTimeout) {
864 - msUntilTimeout = timeUntilExpirationMs;
865 - }
866 - }
919 + msUntilTimeout = jnd(timeElapsed) - timeElapsed;
920
868 - // Don't bother with a very short suspense time.
869 - if (msUntilTimeout > 10) {
870 - // The render is suspended, it hasn't timed out, and there's no
871 - // lower priority work to do. Instead of committing the fallback
872 - // immediately, wait for more data to arrive.
873 - root.timeoutHandle = scheduleTimeout(
874 - commitRoot.bind(null, root),
875 - msUntilTimeout,
876 - );
877 - break;
878 - }
879 - }
880 - // The work expired. Commit immediately.
881 - commitRoot(root);
882 - break;
883 - }
884 - case RootCompleted: {
885 - // The work completed. Ready to commit.
886 - if (
887 - // do not delay if we're inside an act() scope
888 - !(
889 - __DEV__ &&
890 - flushSuspenseFallbacksInTests &&
891 - IsThisRendererActing.current
892 - ) &&
893 - workInProgressRootLatestProcessedExpirationTime !== Sync &&
894 - workInProgressRootCanSuspendUsingConfig !== null
895 - ) {
896 - // If we have exceeded the minimum loading delay, which probably
897 - // means we have shown a spinner already, we might have to suspend
898 - // a bit longer to ensure that the spinner is shown for
899 - // enough time.
900 - const msUntilTimeout = computeMsUntilSuspenseLoadingDelay(
901 - workInProgressRootLatestProcessedExpirationTime,
902 - expirationTime,
903 - workInProgressRootCanSuspendUsingConfig,
904 - );
905 - if (msUntilTimeout > 10) {
906 - markRootSuspendedAtTime(root, expirationTime);
907 - root.timeoutHandle = scheduleTimeout(
908 - commitRoot.bind(null, root),
909 - msUntilTimeout,
910 - );
911 - break;
912 - }
913 - }
914 - commitRoot(root);
915 - break;
916 - }
917 - case RootLocked: {
918 - // This root has a lock that prevents it from committing. Exit. If
919 - // we begin work on the root again, without any intervening updates,
920 - // it will finish without doing additional work.
921 - markRootSuspendedAtTime(root, expirationTime);
922 - break;
923 - }
924 - default: {
925 - invariant(false, 'Unknown root exit status.');
921 + // Clamp the timeout to the expiration time. TODO: Once the
922 + // event time is exact instead of inferred from expiration time
923 + // we don't need this.
924 + if (timeUntilExpirationMs < msUntilTimeout) {
925 + msUntilTimeout = timeUntilExpirationMs;
926 }
927 }
928 +
929 + // Don't bother with a very short suspense time.
930 + if (msUntilTimeout > 10) {
931 + // The render is suspended, it hasn't timed out, and there's no
932 + // lower priority work to do. Instead of committing the fallback
933 + // immediately, wait for more data to arrive.
934 + root.timeoutHandle = scheduleTimeout(
935 + commitRoot.bind(null, root),
936 + msUntilTimeout,
937 + );
938 + break;
939 + }
940 }
929 - // Before exiting, make sure there's a callback scheduled for the
930 - // pending level. This is intentionally duplicated in the `catch` block,
931 - // instead of using `finally`, because it needs to happen before we
932 - // possibly return a continuation, and we can't return in the `finally`
933 - // block without suppressing a potential error.
934 - ensureRootIsScheduled(root);
935 - if (root.callbackNode === originalCallbackNode) {
936 - // The task node scheduled for this root is the same one that's
937 - // currently executed. Need to return a continuation.
938 - return performConcurrentWorkOnRoot.bind(null, root);
941 + // The work expired. Commit immediately.
942 + commitRoot(root);
943 + break;
944 + }
945 + case RootCompleted: {
946 + // The work completed. Ready to commit.
947 + if (
948 + // do not delay if we're inside an act() scope
949 + !(
950 + __DEV__ &&
951 + flushSuspenseFallbacksInTests &&
952 + IsThisRendererActing.current
953 + ) &&
954 + workInProgressRootLatestProcessedExpirationTime !== Sync &&
955 + workInProgressRootCanSuspendUsingConfig !== null
956 + ) {
957 + // If we have exceeded the minimum loading delay, which probably
958 + // means we have shown a spinner already, we might have to suspend
959 + // a bit longer to ensure that the spinner is shown for
960 + // enough time.
961 + const msUntilTimeout = computeMsUntilSuspenseLoadingDelay(
962 + workInProgressRootLatestProcessedExpirationTime,
963 + expirationTime,
964 + workInProgressRootCanSuspendUsingConfig,
965 + );
966 + if (msUntilTimeout > 10) {
967 + markRootSuspendedAtTime(root, expirationTime);
968 + root.timeoutHandle = scheduleTimeout(
969 + commitRoot.bind(null, root),
970 + msUntilTimeout,
971 + );
972 + break;
973 + }
974 }
940 - } catch (error) {
941 - ensureRootIsScheduled(root);
942 - throw error;
975 + commitRoot(root);
976 + break;
977 + }
978 + case RootLocked: {
979 + // This root has a lock that prevents it from committing. Exit. If
980 + // we begin work on the root again, without any intervening updates,
981 + // it will finish without doing additional work.
982 + markRootSuspendedAtTime(root, expirationTime);
983 + break;
984 + }
985 + default: {
986 + invariant(false, 'Unknown root exit status.');
987 }
988 }
945 - return null;
989 }
990
991 // This is the entry point for synchronous tasks that don't go
@@ -951,56 +994,111 @@ function performSyncWorkOnRoot(root) {
994 // Check if there's expired work on this root. Otherwise, render at Sync.
995 const lastExpiredTime = root.lastExpiredTime;
996 const expirationTime = lastExpiredTime !== NoWork ? lastExpiredTime : Sync;
954 - try {
955 - if (root.finishedExpirationTime === expirationTime) {
956 - // There's already a pending commit at this expiration time.
957 - // TODO: This is poorly factored. This case only exists for the
958 - // batch.commit() API.
959 - commitRoot(root);
960 - } else {
961 - renderRoot(root, expirationTime, true);
962 - invariant(
963 - workInProgressRootExitStatus !== RootIncomplete,
964 - 'Cannot commit an incomplete root. This error is likely caused by a ' +
965 - 'bug in React. Please file an issue.',
966 - );
997 + if (root.finishedExpirationTime === expirationTime) {
998 + // There's already a pending commit at this expiration time.
999 + // TODO: This is poorly factored. This case only exists for the
1000 + // batch.commit() API.
1001 + commitRoot(root);
1002 + } else {
1003 + invariant(
1004 + (executionContext & (RenderContext | CommitContext)) === NoContext,
1005 + 'Should not already be working.',
1006 + );
1007
968 - // We now have a consistent tree. The next step is either to commit it,
969 - // or, if something suspended, wait to commit it after a timeout.
970 - stopFinishedWorkLoopTimer();
1008 + flushPassiveEffects();
1009
972 - root.finishedWork = ((root.current.alternate: any): Fiber);
973 - root.finishedExpirationTime = expirationTime;
1010 + // If the root or expiration time have changed, throw out the existing stack
1011 + // and prepare a fresh one. Otherwise we'll continue where we left off.
1012 + if (
1013 + root !== workInProgressRoot ||
1014 + expirationTime !== renderExpirationTime
1015 + ) {
1016 + prepareFreshStack(root, expirationTime);
1017 + startWorkOnPendingInteractions(root, expirationTime);
1018 + }
1019
975 - resolveLocksOnRoot(root, expirationTime);
976 - if (workInProgressRootExitStatus === RootLocked) {
977 - // This root has a lock that prevents it from committing. Exit. If we
978 - // begin work on the root again, without any intervening updates, it
979 - // will finish without doing additional work.
980 - markRootSuspendedAtTime(root, expirationTime);
981 - } else {
982 - // Set this to null to indicate there's no in-progress render.
983 - workInProgressRoot = null;
1020 + // If we have a work-in-progress fiber, it means there's still work to do
1021 + // in this root.
1022 + if (workInProgress !== null) {
1023 + const prevExecutionContext = executionContext;
1024 + executionContext |= RenderContext;
1025 + const prevDispatcher = pushDispatcher(root);
1026 + const prevInteractions = pushInteractions(root);
1027 + startWorkLoopTimer(workInProgress);
1028
985 - if (__DEV__) {
986 - if (
987 - workInProgressRootExitStatus === RootSuspended ||
988 - workInProgressRootExitStatus === RootSuspendedWithDelay
989 - ) {
990 - flushSuspensePriorityWarningInDEV();
991 - }
1029 + do {
1030 + try {
1031 + workLoopSync();
1032 + break;
1033 + } catch (thrownValue) {
1034 + handleError(root, thrownValue);
1035 }
993 - commitRoot(root);
1036 + } while (true);
1037 + resetContextDependencies();
1038 + executionContext = prevExecutionContext;
1039 + popDispatcher(prevDispatcher);
1040 + if (enableSchedulerTracing) {
1041 + popInteractions(((prevInteractions: any): Set<Interaction>));
1042 + }
1043 +
1044 + if (workInProgressRootExitStatus === RootFatalErrored) {
1045 + const fatalError = workInProgressRootFatalError;
1046 + stopInterruptedWorkLoopTimer();
1047 + prepareFreshStack(root, expirationTime);
1048 + markRootSuspendedAtTime(root, expirationTime);
1049 + ensureRootIsScheduled(root);
1050 + throw fatalError;
1051 }
1052 +
1053 + if (workInProgress !== null) {
1054 + // This is a sync render, so we should have finished the whole tree.
1055 + invariant(
1056 + false,
1057 + 'Cannot commit an incomplete root. This error is likely caused by a ' +
1058 + 'bug in React. Please file an issue.',
1059 + );
1060 + } else {
1061 + // We now have a consistent tree. Because this is a sync render, we
1062 + // will commit it even if something suspended. The only exception is
1063 + // if the root is locked (using the unstable_createBatch API).
1064 + stopFinishedWorkLoopTimer();
1065 + root.finishedWork = (root.current.alternate: any);
1066 + root.finishedExpirationTime = expirationTime;
1067 + resolveLocksOnRoot(root, expirationTime);
1068 + finishSyncRender(root, workInProgressRootExitStatus, expirationTime);
1069 + }
1070 +
1071 + // Before exiting, make sure there's a callback scheduled for the next
1072 + // pending level.
1073 + ensureRootIsScheduled(root);
1074 }
996 - } finally {
997 - // Before exiting, make sure there's a callback scheduled for the
998 - // pending level.
999 - ensureRootIsScheduled(root);
1075 }
1076 +
1077 return null;
1078 }
1079
1080 +function finishSyncRender(root, exitStatus, expirationTime) {
1081 + if (exitStatus === RootLocked) {
1082 + // This root has a lock that prevents it from committing. Exit. If we
1083 + // begin work on the root again, without any intervening updates, it
1084 + // will finish without doing additional work.
1085 + markRootSuspendedAtTime(root, expirationTime);
1086 + } else {
1087 + // Set this to null to indicate there's no in-progress render.
1088 + workInProgressRoot = null;
1089 +
1090 + if (__DEV__) {
1091 + if (
1092 + exitStatus === RootSuspended ||
1093 + exitStatus === RootSuspendedWithDelay
1094 + ) {
1095 + flushSuspensePriorityWarningInDEV();
1096 + }
1097 + }
1098 + commitRoot(root);
1099 + }
1100 +}
1101 +
1102 export function flushRoot(root: FiberRoot, expirationTime: ExpirationTime) {
1103 if ((executionContext & (RenderContext | CommitContext)) !== NoContext) {
1104 invariant(
@@ -1207,6 +1305,7 @@ function prepareFreshStack(root, expirationTime) {
1305 workInProgress = createWorkInProgress(root.current, null, expirationTime);
1306 renderExpirationTime = expirationTime;
1307 workInProgressRootExitStatus = RootIncomplete;
1308 + workInProgressRootFatalError = null;
1309 workInProgressRootLatestProcessedExpirationTime = Sync;
1310 workInProgressRootLatestSuspenseTimeout = Sync;
1311 workInProgressRootCanSuspendUsingConfig = null;
@@ -1223,102 +1322,77 @@ function prepareFreshStack(root, expirationTime) {
1322 }
1323 }
1324
1226 -// renderRoot should only be called from inside either
1227 -// `performConcurrentWorkOnRoot` or `performSyncWorkOnRoot`.
1228 -function renderRoot(
1229 - root: FiberRoot,
1230 - expirationTime: ExpirationTime,
1231 - isSync: boolean,
1232 -): void {
1233 - invariant(
1234 - (executionContext & (RenderContext | CommitContext)) === NoContext,
1235 - 'Should not already be working.',
1236 - );
1325 +function handleError(root, thrownValue) {
1326 + do {
1327 + try {
1328 + // Reset module-level state that was set during the render phase.
1329 + resetContextDependencies();
1330 + resetHooks();
1331
1238 - flushPassiveEffects();
1332 + if (workInProgress === null || workInProgress.return === null) {
1333 + // Expected to be working on a non-root fiber. This is a fatal error
1334 + // because there's no ancestor that can handle it; the root is
1335 + // supposed to capture all errors that weren't caught by an error
1336 + // boundary.
1337 + workInProgressRootExitStatus = RootFatalErrored;
1338 + workInProgressRootFatalError = thrownValue;
1339 + return null;
1340 + }
1341
1240 - // If the root or expiration time have changed, throw out the existing stack
1241 - // and prepare a fresh one. Otherwise we'll continue where we left off.
1242 - if (root !== workInProgressRoot || expirationTime !== renderExpirationTime) {
1243 - prepareFreshStack(root, expirationTime);
1244 - startWorkOnPendingInteractions(root, expirationTime);
1245 - }
1342 + if (enableProfilerTimer && workInProgress.mode & ProfileMode) {
1343 + // Record the time spent rendering before an error was thrown. This
1344 + // avoids inaccurate Profiler durations in the case of a
1345 + // suspended render.
1346 + stopProfilerTimerIfRunningAndRecordDelta(workInProgress, true);
1347 + }
1348
1247 - // If we have a work-in-progress fiber, it means there's still work to do
1248 - // in this root.
1249 - if (workInProgress !== null) {
1250 - const prevExecutionContext = executionContext;
1251 - executionContext |= RenderContext;
1252 - let prevDispatcher = ReactCurrentDispatcher.current;
1253 - if (prevDispatcher === null) {
1254 - // The React isomorphic package does not include a default dispatcher.
1255 - // Instead the first renderer will lazily attach one, in order to give
1256 - // nicer error messages.
1257 - prevDispatcher = ContextOnlyDispatcher;
1258 - }
1259 - ReactCurrentDispatcher.current = ContextOnlyDispatcher;
1260 - let prevInteractions: Set<Interaction> | null = null;
1261 - if (enableSchedulerTracing) {
1262 - prevInteractions = __interactionsRef.current;
1263 - __interactionsRef.current = root.memoizedInteractions;
1349 + throwException(
1350 + root,
1351 + workInProgress.return,
1352 + workInProgress,
1353 + thrownValue,
1354 + renderExpirationTime,
1355 + );
1356 + workInProgress = completeUnitOfWork(workInProgress);
1357 + } catch (yetAnotherThrownValue) {
1358 + // Something in the return path also threw.
1359 + thrownValue = yetAnotherThrownValue;
1360 + continue;
1361 }
1362 + // Return to the normal work loop.
1363 + return;
1364 + } while (true);
1365 +}
1366
1266 - startWorkLoopTimer(workInProgress);
1267 -
1268 - do {
1269 - try {
1270 - // TODO: This is now the only place that `isSync` is used. Consider
1271 - // outlining the contents of `renderRoot`.
1272 - if (isSync) {
1273 - workLoopSync();
1274 - } else {
1275 - workLoop();
1276 - }
1277 - break;
1278 - } catch (thrownValue) {
1279 - // Reset module-level state that was set during the render phase.
1280 - resetContextDependencies();
1281 - resetHooks();
1282 -
1283 - const sourceFiber = workInProgress;
1284 - if (sourceFiber === null || sourceFiber.return === null) {
1285 - // Expected to be working on a non-root fiber. This is a fatal error
1286 - // because there's no ancestor that can handle it; the root is
1287 - // supposed to capture all errors that weren't caught by an error
1288 - // boundary.
1289 - prepareFreshStack(root, expirationTime);
1290 - executionContext = prevExecutionContext;
1291 - markRootSuspendedAtTime(root, expirationTime);
1292 - throw thrownValue;
1293 - }
1367 +function pushDispatcher(root) {
1368 + const prevDispatcher = ReactCurrentDispatcher.current;
1369 + ReactCurrentDispatcher.current = ContextOnlyDispatcher;
1370 + if (prevDispatcher === null) {
1371 + // The React isomorphic package does not include a default dispatcher.
1372 + // Instead the first renderer will lazily attach one, in order to give
1373 + // nicer error messages.
1374 + return ContextOnlyDispatcher;
1375 + } else {
1376 + return prevDispatcher;
1377 + }
1378 +}
1379
1295 - if (enableProfilerTimer && sourceFiber.mode & ProfileMode) {
1296 - // Record the time spent rendering before an error was thrown. This
1297 - // avoids inaccurate Profiler durations in the case of a
1298 - // suspended render.
1299 - stopProfilerTimerIfRunningAndRecordDelta(sourceFiber, true);
1300 - }
1380 +function popDispatcher(prevDispatcher) {
1381 + ReactCurrentDispatcher.current = prevDispatcher;
1382 +}
1383
1302 - const returnFiber = sourceFiber.return;
1303 - throwException(
1304 - root,
1305 - returnFiber,
1306 - sourceFiber,
1307 - thrownValue,
1308 - renderExpirationTime,
1309 - );
1310 - // TODO: This is not wrapped in a try-catch, so if the complete phase
1311 - // throws, we won't capture it.
1312 - workInProgress = completeUnitOfWork(sourceFiber);
1313 - }
1314 - } while (true);
1384 +function pushInteractions(root) {
1385 + if (enableSchedulerTracing) {
1386 + const prevInteractions: Set<Interaction> | null = __interactionsRef.current;
1387 + __interactionsRef.current = root.memoizedInteractions;
1388 + return prevInteractions;
1389 + }
1390 + return null;
1391 +}
1392
1316 - executionContext = prevExecutionContext;
1317 - resetContextDependencies();
1318 - ReactCurrentDispatcher.current = prevDispatcher;
1319 - if (enableSchedulerTracing) {
1320 - __interactionsRef.current = ((prevInteractions: any): Set<Interaction>);
1321 - }
1393 +function popInteractions(prevInteractions) {
1394 + if (enableSchedulerTracing) {
1395 + __interactionsRef.current = prevInteractions;
1396 }
1397 }
1398
@@ -1432,7 +1506,7 @@ function workLoopSync() {
1506 }
1507
1508 /** @noinline */
1435 -function workLoop() {
1509 +function workLoopConcurrent() {
1510 // Perform work until Scheduler asks us to yield
1511 while (workInProgress !== null && !shouldYield()) {
1512 workInProgress = performUnitOfWork(workInProgress);
@@ -1760,11 +1834,7 @@ function commitRootImpl(root, renderPriorityLevel) {
1834 if (firstEffect !== null) {
1835 const prevExecutionContext = executionContext;
1836 executionContext |= CommitContext;
1763 - let prevInteractions: Set<Interaction> | null = null;
1764 - if (enableSchedulerTracing) {
1765 - prevInteractions = __interactionsRef.current;
1766 - __interactionsRef.current = root.memoizedInteractions;
1767 - }
1837 + const prevInteractions = pushInteractions(root);
1838
1839 // Reset this to null before calling lifecycles
1840 ReactCurrentOwner.current = null;
@@ -1882,7 +1952,7 @@ function commitRootImpl(root, renderPriorityLevel) {
1952 requestPaint();
1953
1954 if (enableSchedulerTracing) {
1885 - __interactionsRef.current = ((prevInteractions: any): Set<Interaction>);
1955 + popInteractions(((prevInteractions: any): Set<Interaction>));
1956 }
1957 executionContext = prevExecutionContext;
1958 } else {
@@ -2151,18 +2221,13 @@ export function flushPassiveEffects() {
2221 }
2222
2223 function flushPassiveEffectsImpl(root, expirationTime) {
2154 - let prevInteractions: Set<Interaction> | null = null;
2155 - if (enableSchedulerTracing) {
2156 - prevInteractions = __interactionsRef.current;
2157 - __interactionsRef.current = root.memoizedInteractions;
2158 - }
2159 -
2224 invariant(
2225 (executionContext & (RenderContext | CommitContext)) === NoContext,
2226 'Cannot flush passive effects while already rendering.',
2227 );
2228 const prevExecutionContext = executionContext;
2229 executionContext |= CommitContext;
2230 + const prevInteractions = pushInteractions(root);
2231
2232 // Note: This currently assumes there are no passive effects on the root
2233 // fiber, because the root is not part of its own effect list. This could
@@ -2193,7 +2258,7 @@ function flushPassiveEffectsImpl(root, expirationTime) {
2258 }
2259
2260 if (enableSchedulerTracing) {
2196 - __interactionsRef.current = ((prevInteractions: any): Set<Interaction>);
2261 + popInteractions(((prevInteractions: any): Set<Interaction>));
2262 finishPendingInteractions(root, expirationTime);
2263 }
2264
packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js
+34
@@ -1392,6 +1392,40 @@ describe('ReactSuspenseWithNoopRenderer', () => {
1392 expect(Scheduler).toFlushExpired(['Hi']);
1393 });
1394 }
1395 +
1396 + it('handles errors in the return path of a component that suspends', async () => {
1397 + // Covers an edge case where an error is thrown inside the complete phase
1398 + // of a component that is in the return path of a component that suspends.
1399 + // The second error should also be handled (i.e. able to be captured by
1400 + // an error boundary.
1401 + class ErrorBoundary extends React.Component {
1402 + state = {error: null};
1403 + static getDerivedStateFromError(error, errorInfo) {
1404 + return {error};
1405 + }
1406 + render() {
1407 + if (this.state.error) {
1408 + return `Caught an error: ${this.state.error.message}`;
1409 + }
1410 + return this.props.children;
1411 + }
1412 + }
1413 +
1414 + ReactNoop.renderLegacySyncRoot(
1415 + <ErrorBoundary>
1416 + <Suspense fallback="Loading...">
1417 + <errorInCompletePhase>
1418 + <AsyncText ms={1000} text="Async" />
1419 + </errorInCompletePhase>
1420 + </Suspense>
1421 + </ErrorBoundary>,
1422 + );
1423 +
1424 + expect(Scheduler).toHaveYielded(['Suspend! [Async]']);
1425 + expect(ReactNoop).toMatchRenderedOutput(
1426 + 'Caught an error: Error in host config.',
1427 + );
1428 + });
1429 });
1430
1431 it('does not call lifecycles of a suspended component', async () => {
scripts/error-codes/codes.json
+2 -1
@@ -342,5 +342,6 @@
342 "341": "We just came from a parent so we must have had a parent. This is a bug in React.",
343 "342": "A React component suspended while rendering, but no fallback UI was specified.\n\nAdd a <Suspense fallback=...> component higher in the tree to provide a loading indicator or placeholder to display.",
344 "343": "ReactDOMServer does not yet support scope components.",
345 - "344": "Expected prepareToHydrateHostSuspenseInstance() to never be called. This error is likely caused by a bug in React. Please file an issue."
345 + "344": "Expected prepareToHydrateHostSuspenseInstance() to never be called. This error is likely caused by a bug in React. Please file an issue.",
346 + "345": "Root did not complete. This is a bug in React."
347 }