@samitouri / QOS-React-2 / commits / 96f3b7d1c3

Warn if calling setState outside of render but before commit (#18838)

* Don't attempt to render the children of a dehydrated Suspense boundary The DehydratedFragment tag doesn't exist so doing so throws. This can happen if we schedule childExpirationTime on the boundary and bail out. * Warn if scheduling work on a component before it is committed

Sebastian Markbåge committed May 6, 2020 at 09:45 UTC 96f3b7d1c3e1d492d8ff645a586c18215abe2a50
5 files changed +219 -2
packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js
+69
@@ -853,6 +853,75 @@ describe('ReactDOMServerPartialHydration', () => {
853 expect(span.className).toBe('hi');
854 });
855
856 + // @gate experimental
857 + it('warns but works if setState is called before commit in a dehydrated component', async () => {
858 + let suspend = false;
859 + let resolve;
860 + const promise = new Promise(resolvePromise => (resolve = resolvePromise));
861 +
862 + let updateText;
863 +
864 + function Child() {
865 + const [state, setState] = React.useState('Hello');
866 + updateText = setState;
867 + Scheduler.unstable_yieldValue('Child');
868 + if (suspend) {
869 + throw promise;
870 + } else {
871 + return state;
872 + }
873 + }
874 +
875 + function Sibling() {
876 + Scheduler.unstable_yieldValue('Sibling');
877 + return null;
878 + }
879 +
880 + function App() {
881 + return (
882 + <div>
883 + <Suspense fallback="Loading...">
884 + <Child />
885 + <Sibling />
886 + </Suspense>
887 + </div>
888 + );
889 + }
890 +
891 + suspend = false;
892 + const finalHTML = ReactDOMServer.renderToString(<App />);
893 + expect(Scheduler).toHaveYielded(['Child', 'Sibling']);
894 +
895 + const container = document.createElement('div');
896 + container.innerHTML = finalHTML;
897 +
898 + const root = ReactDOM.createRoot(container, {hydrate: true});
899 +
900 + await act(async () => {
901 + suspend = true;
902 + root.render(<App />);
903 + expect(Scheduler).toFlushAndYieldThrough(['Child']);
904 +
905 + // While we're part way through the hydration, we update the state.
906 + // This will schedule an update on the children of the suspense boundary.
907 + expect(() => updateText('Hi')).toErrorDev(
908 + "Can't perform a React state update on a component that hasn't mounted yet.",
909 + );
910 +
911 + // This will throw it away and rerender.
912 + expect(Scheduler).toFlushAndYield(['Child', 'Sibling']);
913 +
914 + expect(container.textContent).toBe('Hello');
915 +
916 + suspend = false;
917 + resolve();
918 + await promise;
919 + });
920 + expect(Scheduler).toHaveYielded(['Child', 'Sibling']);
921 +
922 + expect(container.textContent).toBe('Hello');
923 + });
924 +
925 // @gate experimental
926 it('blocks the update to hydrate first if context has changed', async () => {
927 let suspend = false;
packages/react-reconciler/src/ReactFiberBeginWork.new.js
+3 -1
@@ -3112,7 +3112,9 @@ function beginWork(
3112 // been unsuspended it has committed as a resolved Suspense component.
3113 // If it needs to be retried, it should have work scheduled on it.
3114 workInProgress.effectTag |= DidCapture;
3115 - break;
3115 + // We should never render the children of a dehydrated boundary until we
3116 + // upgrade it. We return null instead of bailoutOnAlreadyFinishedWork.
3117 + return null;
3118 }
3119 }
3120
packages/react-reconciler/src/ReactFiberBeginWork.old.js
+4 -1
@@ -1110,6 +1110,8 @@ function updateHostComponent(current, workInProgress, renderExpirationTime) {
1110 }
1111 // Schedule this fiber to re-render at offscreen priority. Then bailout.
1112 workInProgress.expirationTime = workInProgress.childExpirationTime = Never;
1113 + // We should never render the children of a dehydrated boundary until we
1114 + // upgrade it. We return null instead of bailoutOnAlreadyFinishedWork.
1115 return null;
1116 }
1117
@@ -3128,7 +3130,8 @@ function beginWork(
3130 // been unsuspended it has committed as a resolved Suspense component.
3131 // If it needs to be retried, it should have work scheduled on it.
3132 workInProgress.effectTag |= DidCapture;
3131 - break;
3133 +
3134 + return null;
3135 }
3136 }
3137
packages/react-reconciler/src/ReactFiberWorkLoop.new.js
+71
@@ -76,6 +76,7 @@ import {
76 } from './ReactTypeOfMode';
77 import {
78 HostRoot,
79 + IndeterminateComponent,
80 ClassComponent,
81 SuspenseComponent,
82 SuspenseListComponent,
@@ -500,6 +501,14 @@ function markUpdateLaneFromFiberToRoot(
501 if (alternate !== null) {
502 alternate.lanes = mergeLanes(alternate.lanes, lane);
503 }
504 + if (__DEV__) {
505 + if (
506 + alternate === null &&
507 + (fiber.effectTag & (Placement | Hydrating)) !== NoEffect
508 + ) {
509 + warnAboutUpdateOnNotYetMountedFiberInDEV(fiber);
510 + }
511 + }
512 // Walk the parent path to the root and update the child expiration time.
513 let node = fiber.return;
514 let root = null;
@@ -508,6 +517,14 @@ function markUpdateLaneFromFiberToRoot(
517 } else {
518 while (node !== null) {
519 alternate = node.alternate;
520 + if (__DEV__) {
521 + if (
522 + alternate === null &&
523 + (node.effectTag & (Placement | Hydrating)) !== NoEffect
524 + ) {
525 + warnAboutUpdateOnNotYetMountedFiberInDEV(fiber);
526 + }
527 + }
528 node.childLanes = mergeLanes(node.childLanes, lane);
529 if (alternate !== null) {
530 alternate.childLanes = mergeLanes(alternate.childLanes, lane);
@@ -2710,6 +2727,60 @@ function flushRenderPhaseStrictModeWarningsInDEV() {
2727 }
2728 }
2729
2730 +let didWarnStateUpdateForNotYetMountedComponent: Set<string> | null = null;
2731 +function warnAboutUpdateOnNotYetMountedFiberInDEV(fiber) {
2732 + if (__DEV__) {
2733 + if ((executionContext & RenderContext) !== NoContext) {
2734 + // We let the other warning about render phase updates deal with this one.
2735 + return;
2736 + }
2737 +
2738 + const tag = fiber.tag;
2739 + if (
2740 + tag !== IndeterminateComponent &&
2741 + tag !== HostRoot &&
2742 + tag !== ClassComponent &&
2743 + tag !== FunctionComponent &&
2744 + tag !== ForwardRef &&
2745 + tag !== MemoComponent &&
2746 + tag !== SimpleMemoComponent &&
2747 + tag !== Block
2748 + ) {
2749 + // Only warn for user-defined components, not internal ones like Suspense.
2750 + return;
2751 + }
2752 +
2753 + // We show the whole stack but dedupe on the top component's name because
2754 + // the problematic code almost always lies inside that component.
2755 + const componentName = getComponentName(fiber.type) || 'ReactComponent';
2756 + if (didWarnStateUpdateForNotYetMountedComponent !== null) {
2757 + if (didWarnStateUpdateForNotYetMountedComponent.has(componentName)) {
2758 + return;
2759 + }
2760 + didWarnStateUpdateForNotYetMountedComponent.add(componentName);
2761 + } else {
2762 + didWarnStateUpdateForNotYetMountedComponent = new Set([componentName]);
2763 + }
2764 +
2765 + const previousFiber = ReactCurrentFiberCurrent;
2766 + try {
2767 + setCurrentDebugFiberInDEV(fiber);
2768 + console.error(
2769 + "Can't perform a React state update on a component that hasn't mounted yet. " +
2770 + 'This indicates that you have a side-effect in your render function that ' +
2771 + 'asynchronously later calls tries to update the component. Move this work to ' +
2772 + 'useEffect instead.',
2773 + );
2774 + } finally {
2775 + if (previousFiber) {
2776 + setCurrentDebugFiberInDEV(fiber);
2777 + } else {
2778 + resetCurrentDebugFiberInDEV();
2779 + }
2780 + }
2781 + }
2782 +}
2783 +
2784 let didWarnStateUpdateForUnmountedComponent: Set<string> | null = null;
2785 function warnAboutUpdateOnUnmountedFiberInDEV(fiber) {
2786 if (__DEV__) {
packages/react-reconciler/src/ReactFiberWorkLoop.old.js
+72
@@ -96,6 +96,7 @@ import {
96 } from './ReactTypeOfMode';
97 import {
98 HostRoot,
99 + IndeterminateComponent,
100 ClassComponent,
101 SuspenseComponent,
102 SuspenseListComponent,
@@ -498,6 +499,15 @@ function markUpdateTimeFromFiberToRoot(fiber, expirationTime) {
499 if (alternate !== null && alternate.expirationTime < expirationTime) {
500 alternate.expirationTime = expirationTime;
501 }
502 + if (__DEV__) {
503 + if (
504 + alternate === null &&
505 + (fiber.effectTag & (Placement | Hydrating)) !== NoEffect
506 + ) {
507 + warnAboutUpdateOnNotYetMountedFiberInDEV(fiber);
508 + }
509 + }
510 +
511 // Walk the parent path to the root and update the child expiration time.
512 let node = fiber.return;
513 let root = null;
@@ -506,6 +516,14 @@ function markUpdateTimeFromFiberToRoot(fiber, expirationTime) {
516 } else {
517 while (node !== null) {
518 alternate = node.alternate;
519 + if (__DEV__) {
520 + if (
521 + alternate === null &&
522 + (node.effectTag & (Placement | Hydrating)) !== NoEffect
523 + ) {
524 + warnAboutUpdateOnNotYetMountedFiberInDEV(fiber);
525 + }
526 + }
527 if (node.childExpirationTime < expirationTime) {
528 node.childExpirationTime = expirationTime;
529 if (
@@ -2901,6 +2919,60 @@ function flushRenderPhaseStrictModeWarningsInDEV() {
2919 }
2920 }
2921
2922 +let didWarnStateUpdateForNotYetMountedComponent: Set<string> | null = null;
2923 +function warnAboutUpdateOnNotYetMountedFiberInDEV(fiber) {
2924 + if (__DEV__) {
2925 + if ((executionContext & RenderContext) !== NoContext) {
2926 + // We let the other warning about render phase updates deal with this one.
2927 + return;
2928 + }
2929 +
2930 + const tag = fiber.tag;
2931 + if (
2932 + tag !== IndeterminateComponent &&
2933 + tag !== HostRoot &&
2934 + tag !== ClassComponent &&
2935 + tag !== FunctionComponent &&
2936 + tag !== ForwardRef &&
2937 + tag !== MemoComponent &&
2938 + tag !== SimpleMemoComponent &&
2939 + tag !== Block
2940 + ) {
2941 + // Only warn for user-defined components, not internal ones like Suspense.
2942 + return;
2943 + }
2944 +
2945 + // We show the whole stack but dedupe on the top component's name because
2946 + // the problematic code almost always lies inside that component.
2947 + const componentName = getComponentName(fiber.type) || 'ReactComponent';
2948 + if (didWarnStateUpdateForNotYetMountedComponent !== null) {
2949 + if (didWarnStateUpdateForNotYetMountedComponent.has(componentName)) {
2950 + return;
2951 + }
2952 + didWarnStateUpdateForNotYetMountedComponent.add(componentName);
2953 + } else {
2954 + didWarnStateUpdateForNotYetMountedComponent = new Set([componentName]);
2955 + }
2956 +
2957 + const previousFiber = ReactCurrentFiberCurrent;
2958 + try {
2959 + setCurrentDebugFiberInDEV(fiber);
2960 + console.error(
2961 + "Can't perform a React state update on a component that hasn't mounted yet. " +
2962 + 'This indicates that you have a side-effect in your render function that ' +
2963 + 'asynchronously later calls tries to update the component. Move this work to ' +
2964 + 'useEffect instead.',
2965 + );
2966 + } finally {
2967 + if (previousFiber) {
2968 + setCurrentDebugFiberInDEV(fiber);
2969 + } else {
2970 + resetCurrentDebugFiberInDEV();
2971 + }
2972 + }
2973 + }
2974 +}
2975 +
2976 let didWarnStateUpdateForUnmountedComponent: Set<string> | null = null;
2977 function warnAboutUpdateOnUnmountedFiberInDEV(fiber) {
2978 if (__DEV__) {