[Synchronous Suspense] Suspending a class outside concurrent mode (#13926)
* [Synchronous Suspense] Suspending a class outside concurrent mode When a class component suspends during mount outside concurrent mode, change the tag so it's not mistaken for a completed component. For example, we should not call componentWillUnmount if it is deleted. * PR nits
Andrew Clark committed
Oct 23, 2018 at 11:36 UTC
cbbc2b6c4d0d8519145560bd8183ecde55168b12
7 files changed
+148
-6
packages/react-reconciler/src/ReactFiberBeginWork.js
+75
-1
@@ -33,6 +33,7 @@ import {
33
MemoComponent,
34
SimpleMemoComponent,
35
LazyComponent,
36
+ IncompleteClassComponent,
37
} from 'shared/ReactWorkTags';
38
import {
39
NoEffect,
@@ -762,7 +763,7 @@ function mountLazyComponent(
763
renderExpirationTime,
764
) {
765
if (_current !== null) {
765
- // An indeterminate component only mounts if it suspended inside a non-
766
+ // An lazy component only mounts if it suspended inside a non-
767
// concurrent tree, in an inconsistent state. We want to tree it like
768
// a new mount, even though an empty version of it already committed.
769
// Disconnect the alternate pointers.
@@ -840,6 +841,64 @@ function mountLazyComponent(
841
return child;
842
}
843
844
+function mountIncompleteClassComponent(
845
+ _current,
846
+ workInProgress,
847
+ Component,
848
+ nextProps,
849
+ renderExpirationTime,
850
+) {
851
+ if (_current !== null) {
852
+ // An incomplete component only mounts if it suspended inside a non-
853
+ // concurrent tree, in an inconsistent state. We want to tree it like
854
+ // a new mount, even though an empty version of it already committed.
855
+ // Disconnect the alternate pointers.
856
+ _current.alternate = null;
857
+ workInProgress.alternate = null;
858
+ // Since this is conceptually a new fiber, schedule a Placement effect
859
+ workInProgress.effectTag |= Placement;
860
+ }
861
+
862
+ // Promote the fiber to a class and try rendering again.
863
+ workInProgress.tag = ClassComponent;
864
+
865
+ // The rest of this function is a fork of `updateClassComponent`
866
+
867
+ // Push context providers early to prevent context stack mismatches.
868
+ // During mounting we don't know the child context yet as the instance doesn't exist.
869
+ // We will invalidate the child context in finishClassComponent() right after rendering.
870
+ let hasContext;
871
+ if (isLegacyContextProvider(Component)) {
872
+ hasContext = true;
873
+ pushLegacyContextProvider(workInProgress);
874
+ } else {
875
+ hasContext = false;
876
+ }
877
+ prepareToReadContext(workInProgress, renderExpirationTime);
878
+
879
+ constructClassInstance(
880
+ workInProgress,
881
+ Component,
882
+ nextProps,
883
+ renderExpirationTime,
884
+ );
885
+ mountClassInstance(
886
+ workInProgress,
887
+ Component,
888
+ nextProps,
889
+ renderExpirationTime,
890
+ );
891
+
892
+ return finishClassComponent(
893
+ null,
894
+ workInProgress,
895
+ Component,
896
+ true,
897
+ hasContext,
898
+ renderExpirationTime,
899
+ );
900
+}
901
+
902
function mountIndeterminateComponent(
903
_current,
904
workInProgress,
@@ -1654,6 +1713,21 @@ function beginWork(
1713
renderExpirationTime,
1714
);
1715
}
1716
+ case IncompleteClassComponent: {
1717
+ const Component = workInProgress.type;
1718
+ const unresolvedProps = workInProgress.pendingProps;
1719
+ const resolvedProps =
1720
+ workInProgress.elementType === Component
1721
+ ? unresolvedProps
1722
+ : resolveDefaultProps(Component, unresolvedProps);
1723
+ return mountIncompleteClassComponent(
1724
+ current,
1725
+ workInProgress,
1726
+ Component,
1727
+ resolvedProps,
1728
+ renderExpirationTime,
1729
+ );
1730
+ }
1731
default:
1732
invariant(
1733
false,
packages/react-reconciler/src/ReactFiberCommitWork.js
+14
-1
@@ -32,6 +32,7 @@ import {
32
HostPortal,
33
Profiler,
34
SuspenseComponent,
35
+ IncompleteClassComponent,
36
} from 'shared/ReactWorkTags';
37
import {
38
invokeGuardedCallback,
@@ -221,6 +222,7 @@ function commitBeforeMutationLifeCycles(
222
case HostComponent:
223
case HostText:
224
case HostPortal:
225
+ case IncompleteClassComponent:
226
// Nothing to do for these component types
227
return;
228
default: {
@@ -392,6 +394,8 @@ function commitLifeCycles(
394
}
395
return;
396
}
397
+ case IncompleteClassComponent:
398
+ break;
399
default: {
400
invariant(
401
false,
@@ -495,7 +499,13 @@ function commitUnmount(current: Fiber): void {
499
case ClassComponent: {
500
safelyDetachRef(current);
501
const instance = current.stateNode;
498
- if (typeof instance.componentWillUnmount === 'function') {
502
+ if (
503
+ // Typically, a component that mounted will have an instance. However,
504
+ // outside of concurrent mode, a suspended component may commit without
505
+ // an instance, so we need to check whether it exists.
506
+ instance !== null &&
507
+ typeof instance.componentWillUnmount === 'function'
508
+ ) {
509
safelyCallComponentWillUnmount(current, instance);
510
}
511
return;
@@ -924,6 +934,9 @@ function commitWork(current: Fiber | null, finishedWork: Fiber): void {
934
case SuspenseComponent: {
935
return;
936
}
937
+ case IncompleteClassComponent: {
938
+ return;
939
+ }
940
default: {
941
invariant(
942
false,
packages/react-reconciler/src/ReactFiberCompleteWork.js
+10
@@ -37,6 +37,7 @@ import {
37
MemoComponent,
38
SimpleMemoComponent,
39
LazyComponent,
40
+ IncompleteClassComponent,
41
} from 'shared/ReactWorkTags';
42
import {Placement, Ref, Update} from 'shared/ReactSideEffectTags';
43
import invariant from 'shared/invariant';
@@ -717,6 +718,15 @@ function completeWork(
718
break;
719
case MemoComponent:
720
break;
721
+ case IncompleteClassComponent: {
722
+ // Same as class component case. I put it down here so that the tags are
723
+ // sequential to ensure this switch is compiled to a jump table.
724
+ const Component = workInProgress.type;
725
+ if (isLegacyContextProvider(Component)) {
726
+ popLegacyContext(workInProgress);
727
+ }
728
+ break;
729
+ }
730
default:
731
invariant(
732
false,
packages/react-reconciler/src/ReactFiberUnwindWork.js
+7
-4
@@ -25,6 +25,7 @@ import {
25
HostPortal,
26
ContextProvider,
27
SuspenseComponent,
28
+ IncompleteClassComponent,
29
} from 'shared/ReactWorkTags';
30
import {
31
DidCapture,
@@ -252,10 +253,12 @@ function throwException(
253
// But we shouldn't call any lifecycle methods or callbacks. Remove
254
// all lifecycle effect tags.
255
sourceFiber.effectTag &= ~LifecycleEffectMask;
255
- if (sourceFiber.alternate === null) {
256
- // Set the instance back to null. We use this as a heuristic to
257
- // detect that the fiber mounted in an inconsistent state.
258
- sourceFiber.stateNode = null;
256
+ const current = sourceFiber.alternate;
257
+ if (current === null) {
258
+ // This is a new mount. Change the tag so it's not mistaken for a
259
+ // completed component. For example, we should not call
260
+ // componentWillUnmount if it is deleted.
261
+ sourceFiber.tag = IncompleteClassComponent;
262
}
263
}
264
packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js
+39
@@ -492,6 +492,45 @@ describe('ReactSuspense', () => {
492
expect(root).toMatchRenderedOutput('Stateful: 2B');
493
});
494
495
+ it('suspends in a class that has componentWillUnmount and is then deleted', () => {
496
+ class AsyncTextWithUnmount extends React.Component {
497
+ componentWillUnmount() {
498
+ ReactTestRenderer.unstable_yield('will unmount');
499
+ }
500
+ render() {
501
+ const text = this.props.text;
502
+ const ms = this.props.ms;
503
+ try {
504
+ TextResource.read(cache, [text, ms]);
505
+ ReactTestRenderer.unstable_yield(text);
506
+ return text;
507
+ } catch (promise) {
508
+ if (typeof promise.then === 'function') {
509
+ ReactTestRenderer.unstable_yield(`Suspend! [${text}]`);
510
+ } else {
511
+ ReactTestRenderer.unstable_yield(`Error! [${text}]`);
512
+ }
513
+ throw promise;
514
+ }
515
+ }
516
+ }
517
+
518
+ function App({text}) {
519
+ return (
520
+ <Suspense fallback={<Text text="Loading..." />}>
521
+ <AsyncTextWithUnmount text={text} ms={100} />
522
+ </Suspense>
523
+ );
524
+ }
525
+
526
+ const root = ReactTestRenderer.create(<App text="A" />);
527
+ expect(ReactTestRenderer).toHaveYielded(['Suspend! [A]', 'Loading...']);
528
+ root.update(<Text text="B" />);
529
+ // Should not fire componentWillUnmount
530
+ expect(ReactTestRenderer).toHaveYielded(['B']);
531
+ expect(root).toMatchRenderedOutput('B');
532
+ });
533
+
534
it('retries when an update is scheduled on a timed out tree', () => {
535
let instance;
536
class Stateful extends React.Component {
packages/react-test-renderer/src/ReactTestRenderer.js
+2
@@ -29,6 +29,7 @@ import {
29
Profiler,
30
MemoComponent,
31
SimpleMemoComponent,
32
+ IncompleteClassComponent,
33
} from 'shared/ReactWorkTags';
34
import invariant from 'shared/invariant';
35
import ReactVersion from 'shared/ReactVersion';
@@ -193,6 +194,7 @@ function toTree(node: ?Fiber) {
194
case Profiler:
195
case ForwardRef:
196
case MemoComponent:
197
+ case IncompleteClassComponent:
198
return childrenToTree(node.child);
199
default:
200
invariant(
packages/shared/ReactWorkTags.js
+1
@@ -45,3 +45,4 @@ export const SuspenseComponent = 13;
45
export const MemoComponent = 14;
46
export const SimpleMemoComponent = 15;
47
export const LazyComponent = 16;
48
+export const IncompleteClassComponent = 17;