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__) {