Add suspenseCallback feature for runtime tracing of loading states (#16134)
This adds a 'SuspenseCallback' feature flag. When the property is set on a suspense component it will be called during the commit phase with a set of the immediate thenable for this component. This will allow user code to build runtime tracing of the cause for a suspense boundary.
Benoit Girard committed
Jul 23, 2019 at 17:13 UTC
42b75ab007a5e7c159933cfdbf2b6845d89fc7f2
10 files changed
+251
packages/react-reconciler/src/ReactFiberCommitWork.js
+13
@@ -30,6 +30,7 @@ import {
30
enableSuspenseServerRenderer,
31
enableFlareAPI,
32
enableFundamentalAPI,
33
+ enableSuspenseCallback,
34
} from 'shared/ReactFeatureFlags';
35
import {
36
FunctionComponent,
@@ -1320,6 +1321,18 @@ function commitSuspenseComponent(finishedWork: Fiber) {
1321
if (supportsMutation && primaryChildParent !== null) {
1322
hideOrUnhideAllChildren(primaryChildParent, newDidTimeout);
1323
}
1324
+
1325
+ if (enableSuspenseCallback && newState !== null) {
1326
+ const suspenseCallback = finishedWork.memoizedProps.suspenseCallback;
1327
+ if (typeof suspenseCallback === 'function') {
1328
+ const thenables: Set<Thenable> | null = (finishedWork.updateQueue: any);
1329
+ if (thenables !== null) {
1330
+ suspenseCallback(new Set(thenables));
1331
+ }
1332
+ } else if (__DEV__) {
1333
+ warning(false, 'Unexpected type for suspenseCallback.');
1334
+ }
1335
+ }
1336
}
1337
1338
function attachSuspenseRetryListeners(finishedWork: Fiber) {
packages/react-reconciler/src/ReactFiberCompleteWork.js
+9
@@ -119,6 +119,7 @@ import {
119
} from './ReactFiberHydrationContext';
120
import {
121
enableSchedulerTracing,
122
+ enableSuspenseCallback,
123
enableSuspenseServerRenderer,
124
enableFlareAPI,
125
enableFundamentalAPI,
@@ -916,6 +917,14 @@ function completeWork(
917
workInProgress.effectTag |= Update;
918
}
919
}
920
+ if (
921
+ enableSuspenseCallback &&
922
+ workInProgress.updateQueue !== null &&
923
+ workInProgress.memoizedProps.suspenseCallback != null
924
+ ) {
925
+ // Always notify the callback
926
+ workInProgress.effectTag |= Update;
927
+ }
928
break;
929
}
930
case Fragment:
packages/react-reconciler/src/__tests__/ReactSuspenseCallback-test.internal.js
new
+217
@@ -0,0 +1,217 @@
1
+/**
2
+ * Copyright (c) Facebook, Inc. and its affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * @emails react-core
8
+ * @jest-environment node
9
+ */
10
+'use strict';
11
+
12
+let React;
13
+let ReactFeatureFlags;
14
+let ReactNoop;
15
+let Scheduler;
16
+
17
+describe('ReactSuspense', () => {
18
+ beforeEach(() => {
19
+ jest.resetModules();
20
+ ReactFeatureFlags = require('shared/ReactFeatureFlags');
21
+ ReactFeatureFlags.enableSuspenseCallback = true;
22
+
23
+ React = require('react');
24
+ ReactNoop = require('react-noop-renderer');
25
+ Scheduler = require('scheduler');
26
+ });
27
+
28
+ function text(t) {
29
+ return {text: t, hidden: false};
30
+ }
31
+
32
+ function createThenable() {
33
+ let completed = false;
34
+ const resolveRef = {current: null};
35
+ let promise = {
36
+ then(resolve, reject) {
37
+ resolveRef.current = () => {
38
+ completed = true;
39
+ resolve();
40
+ };
41
+ },
42
+ };
43
+
44
+ const PromiseComp = () => {
45
+ if (!completed) {
46
+ throw promise;
47
+ }
48
+ return 'Done';
49
+ };
50
+ return {promise, resolveRef, PromiseComp};
51
+ }
52
+
53
+ it('1 then 0 suspense callback', () => {
54
+ const {promise, resolveRef, PromiseComp} = createThenable();
55
+
56
+ let ops = [];
57
+ const suspenseCallback = thenables => {
58
+ ops.push(thenables);
59
+ };
60
+
61
+ const element = (
62
+ <React.Suspense suspenseCallback={suspenseCallback} fallback={'Waiting'}>
63
+ <PromiseComp />
64
+ </React.Suspense>
65
+ );
66
+
67
+ ReactNoop.render(element);
68
+ expect(Scheduler).toFlushWithoutYielding();
69
+ expect(ReactNoop.getChildren()).toEqual([text('Waiting')]);
70
+ expect(ops).toEqual([new Set([promise])]);
71
+ ops = [];
72
+
73
+ resolveRef.current();
74
+ expect(Scheduler).toFlushWithoutYielding();
75
+ expect(ReactNoop.getChildren()).toEqual([text('Done')]);
76
+ expect(ops).toEqual([]);
77
+ });
78
+
79
+ it('2 then 1 then 0 suspense callback', () => {
80
+ const {
81
+ promise: promise1,
82
+ resolveRef: resolveRef1,
83
+ PromiseComp: PromiseComp1,
84
+ } = createThenable();
85
+ const {
86
+ promise: promise2,
87
+ resolveRef: resolveRef2,
88
+ PromiseComp: PromiseComp2,
89
+ } = createThenable();
90
+
91
+ let ops = [];
92
+ const suspenseCallback1 = thenables => {
93
+ ops.push(thenables);
94
+ };
95
+
96
+ const element = (
97
+ <React.Suspense
98
+ suspenseCallback={suspenseCallback1}
99
+ fallback={'Waiting Tier 1'}>
100
+ <PromiseComp1 />
101
+ <PromiseComp2 />
102
+ </React.Suspense>
103
+ );
104
+
105
+ ReactNoop.render(element);
106
+ expect(Scheduler).toFlushWithoutYielding();
107
+ expect(ReactNoop.getChildren()).toEqual([text('Waiting Tier 1')]);
108
+ expect(ops).toEqual([new Set([promise1, promise2])]);
109
+ ops = [];
110
+
111
+ resolveRef1.current();
112
+ ReactNoop.render(element);
113
+ expect(Scheduler).toFlushWithoutYielding();
114
+ expect(ReactNoop.getChildren()).toEqual([text('Waiting Tier 1')]);
115
+ expect(ops).toEqual([new Set([promise2])]);
116
+ ops = [];
117
+
118
+ resolveRef2.current();
119
+ ReactNoop.render(element);
120
+ expect(Scheduler).toFlushWithoutYielding();
121
+ expect(ReactNoop.getChildren()).toEqual([text('Done'), text('Done')]);
122
+ expect(ops).toEqual([]);
123
+ });
124
+
125
+ it('nested suspense promises are reported only for their tier', () => {
126
+ const {promise, PromiseComp} = createThenable();
127
+
128
+ let ops1 = [];
129
+ const suspenseCallback1 = thenables => {
130
+ ops1.push(thenables);
131
+ };
132
+ let ops2 = [];
133
+ const suspenseCallback2 = thenables => {
134
+ ops2.push(thenables);
135
+ };
136
+
137
+ const element = (
138
+ <React.Suspense
139
+ suspenseCallback={suspenseCallback1}
140
+ fallback={'Waiting Tier 1'}>
141
+ <React.Suspense
142
+ suspenseCallback={suspenseCallback2}
143
+ fallback={'Waiting Tier 2'}>
144
+ <PromiseComp />
145
+ </React.Suspense>
146
+ </React.Suspense>
147
+ );
148
+
149
+ ReactNoop.render(element);
150
+ expect(Scheduler).toFlushWithoutYielding();
151
+ expect(ReactNoop.getChildren()).toEqual([text('Waiting Tier 2')]);
152
+ expect(ops1).toEqual([]);
153
+ expect(ops2).toEqual([new Set([promise])]);
154
+ });
155
+
156
+ it('competing suspense promises', () => {
157
+ const {
158
+ promise: promise1,
159
+ resolveRef: resolveRef1,
160
+ PromiseComp: PromiseComp1,
161
+ } = createThenable();
162
+ const {
163
+ promise: promise2,
164
+ resolveRef: resolveRef2,
165
+ PromiseComp: PromiseComp2,
166
+ } = createThenable();
167
+
168
+ let ops1 = [];
169
+ const suspenseCallback1 = thenables => {
170
+ ops1.push(thenables);
171
+ };
172
+ let ops2 = [];
173
+ const suspenseCallback2 = thenables => {
174
+ ops2.push(thenables);
175
+ };
176
+
177
+ const element = (
178
+ <React.Suspense
179
+ suspenseCallback={suspenseCallback1}
180
+ fallback={'Waiting Tier 1'}>
181
+ <React.Suspense
182
+ suspenseCallback={suspenseCallback2}
183
+ fallback={'Waiting Tier 2'}>
184
+ <PromiseComp2 />
185
+ </React.Suspense>
186
+ <PromiseComp1 />
187
+ </React.Suspense>
188
+ );
189
+
190
+ ReactNoop.render(element);
191
+ expect(Scheduler).toFlushWithoutYielding();
192
+ expect(ReactNoop.getChildren()).toEqual([text('Waiting Tier 1')]);
193
+ expect(ops1).toEqual([new Set([promise1])]);
194
+ expect(ops2).toEqual([]);
195
+ ops1 = [];
196
+ ops2 = [];
197
+
198
+ resolveRef1.current();
199
+ ReactNoop.render(element);
200
+ expect(Scheduler).toFlushWithoutYielding();
201
+ expect(ReactNoop.getChildren()).toEqual([
202
+ text('Waiting Tier 2'),
203
+ text('Done'),
204
+ ]);
205
+ expect(ops1).toEqual([]);
206
+ expect(ops2).toEqual([new Set([promise2])]);
207
+ ops1 = [];
208
+ ops2 = [];
209
+
210
+ resolveRef2.current();
211
+ ReactNoop.render(element);
212
+ expect(Scheduler).toFlushWithoutYielding();
213
+ expect(ReactNoop.getChildren()).toEqual([text('Done'), text('Done')]);
214
+ expect(ops1).toEqual([]);
215
+ expect(ops2).toEqual([]);
216
+ });
217
+});
packages/shared/ReactFeatureFlags.js
+5
@@ -78,3 +78,8 @@ export const revertPassiveEffectsChange = false;
78
// but without making them discrete. The flag exists in case it causes
79
// starvation problems.
80
export const enableUserBlockingEvents = false;
81
+
82
+// Add a callback property to suspense to notify which promises are currently
83
+// in the update queue. This allows reporting and tracing of what is causing
84
+// the user to see a loading state.
85
+export const enableSuspenseCallback = false;
packages/shared/forks/ReactFeatureFlags.native-fb.js
+1
@@ -37,6 +37,7 @@ export const enableJSXTransformAPI = false;
37
export const warnAboutMissingMockScheduler = true;
38
export const revertPassiveEffectsChange = false;
39
export const enableUserBlockingEvents = false;
40
+export const enableSuspenseCallback = false;
41
42
// Only used in www builds.
43
export function addUserTimingListener() {
packages/shared/forks/ReactFeatureFlags.native-oss.js
+1
@@ -32,6 +32,7 @@ export const enableJSXTransformAPI = false;
32
export const warnAboutMissingMockScheduler = false;
33
export const revertPassiveEffectsChange = false;
34
export const enableUserBlockingEvents = false;
35
+export const enableSuspenseCallback = false;
36
37
// Only used in www builds.
38
export function addUserTimingListener() {
packages/shared/forks/ReactFeatureFlags.persistent.js
+1
@@ -32,6 +32,7 @@ export const enableJSXTransformAPI = false;
32
export const warnAboutMissingMockScheduler = true;
33
export const revertPassiveEffectsChange = false;
34
export const enableUserBlockingEvents = false;
35
+export const enableSuspenseCallback = false;
36
37
// Only used in www builds.
38
export function addUserTimingListener() {
packages/shared/forks/ReactFeatureFlags.test-renderer.js
+1
@@ -32,6 +32,7 @@ export const enableJSXTransformAPI = false;
32
export const warnAboutMissingMockScheduler = false;
33
export const revertPassiveEffectsChange = false;
34
export const enableUserBlockingEvents = false;
35
+export const enableSuspenseCallback = false;
36
37
// Only used in www builds.
38
export function addUserTimingListener() {
packages/shared/forks/ReactFeatureFlags.test-renderer.www.js
+1
@@ -32,6 +32,7 @@ export const enableFundamentalAPI = false;
32
export const enableJSXTransformAPI = true;
33
export const warnAboutMissingMockScheduler = true;
34
export const enableUserBlockingEvents = false;
35
+export const enableSuspenseCallback = true;
36
37
// Only used in www builds.
38
export function addUserTimingListener() {
packages/shared/forks/ReactFeatureFlags.www.js
+2
@@ -76,6 +76,8 @@ export const enableJSXTransformAPI = true;
76
77
export const warnAboutMissingMockScheduler = true;
78
79
+export const enableSuspenseCallback = true;
80
+
81
// Flow magic to verify the exports of this file match the original version.
82
// eslint-disable-next-line no-unused-vars
83
type Check<_X, Y: _X, X: Y = _X> = null;