Invalid actualDuration+treeBaseDuration for hidden+suspended trees (#14065)
* Fixed `treeBaseDuration` by propagating its value from the suspended tree to the Fragment React temporarily wraps around it when showing the fallback UI. * Fixed `actualDuration` by recording elapsed profiler time in the event of an error. * Fixed `actualDuration` in concurrent mode by propagating the time spent rendering the suspending component to its parent. Also updated ReactSuspensePlaceholder-test.internal to cover these new cases.
Brian Vaughn committed
Nov 7, 2018 at 15:46 UTC
3ff2c7ccd4d174786aed0f16cc0dd784816ae977
3 files changed
+297
-24
packages/react-reconciler/src/ReactFiberBeginWork.js
+36
-4
@@ -66,7 +66,12 @@ import {
66
} from './ReactChildFiber';
67
import {processUpdateQueue} from './ReactUpdateQueue';
68
import {NoWork, Never} from './ReactFiberExpirationTime';
69
-import {ConcurrentMode, StrictMode, NoContext} from './ReactTypeOfMode';
69
+import {
70
+ ConcurrentMode,
71
+ NoContext,
72
+ ProfileMode,
73
+ StrictMode,
74
+} from './ReactTypeOfMode';
75
import {
76
shouldSetTextContent,
77
shouldDeprioritizeSubtree,
@@ -743,7 +748,7 @@ function mountLazyComponent(
748
) {
749
if (_current !== null) {
750
// An lazy component only mounts if it suspended inside a non-
746
- // concurrent tree, in an inconsistent state. We want to tree it like
751
+ // concurrent tree, in an inconsistent state. We want to treat it like
752
// a new mount, even though an empty version of it already committed.
753
// Disconnect the alternate pointers.
754
_current.alternate = null;
@@ -829,7 +834,7 @@ function mountIncompleteClassComponent(
834
) {
835
if (_current !== null) {
836
// An incomplete component only mounts if it suspended inside a non-
832
- // concurrent tree, in an inconsistent state. We want to tree it like
837
+ // concurrent tree, in an inconsistent state. We want to treat it like
838
// a new mount, even though an empty version of it already committed.
839
// Disconnect the alternate pointers.
840
_current.alternate = null;
@@ -886,7 +891,7 @@ function mountIndeterminateComponent(
891
) {
892
if (_current !== null) {
893
// An indeterminate component only mounts if it suspended inside a non-
889
- // concurrent tree, in an inconsistent state. We want to tree it like
894
+ // concurrent tree, in an inconsistent state. We want to treat it like
895
// a new mount, even though an empty version of it already committed.
896
// Disconnect the alternate pointers.
897
_current.alternate = null;
@@ -1188,6 +1193,19 @@ function updateSuspenseComponent(
1193
}
1194
}
1195
1196
+ // Because primaryChildFragment is a new fiber that we're inserting as the
1197
+ // parent of a new tree, we need to set its treeBaseDuration.
1198
+ if (enableProfilerTimer && workInProgress.mode & ProfileMode) {
1199
+ // treeBaseDuration is the sum of all the child tree base durations.
1200
+ let treeBaseDuration = 0;
1201
+ let hiddenChild = primaryChildFragment.child;
1202
+ while (hiddenChild !== null) {
1203
+ treeBaseDuration += hiddenChild.treeBaseDuration;
1204
+ hiddenChild = hiddenChild.sibling;
1205
+ }
1206
+ primaryChildFragment.treeBaseDuration = treeBaseDuration;
1207
+ }
1208
+
1209
// Clone the fallback child fragment, too. These we'll continue
1210
// working on.
1211
const fallbackChildFragment = (primaryChildFragment.sibling = createWorkInProgress(
@@ -1239,6 +1257,7 @@ function updateSuspenseComponent(
1257
NoWork,
1258
null,
1259
);
1260
+
1261
primaryChildFragment.effectTag |= Placement;
1262
primaryChildFragment.child = currentPrimaryChild;
1263
currentPrimaryChild.return = primaryChildFragment;
@@ -1254,6 +1273,19 @@ function updateSuspenseComponent(
1273
primaryChildFragment.child = progressedPrimaryChild;
1274
}
1275
1276
+ // Because primaryChildFragment is a new fiber that we're inserting as the
1277
+ // parent of a new tree, we need to set its treeBaseDuration.
1278
+ if (enableProfilerTimer && workInProgress.mode & ProfileMode) {
1279
+ // treeBaseDuration is the sum of all the child tree base durations.
1280
+ let treeBaseDuration = 0;
1281
+ let hiddenChild = primaryChildFragment.child;
1282
+ while (hiddenChild !== null) {
1283
+ treeBaseDuration += hiddenChild.treeBaseDuration;
1284
+ hiddenChild = hiddenChild.sibling;
1285
+ }
1286
+ primaryChildFragment.treeBaseDuration = treeBaseDuration;
1287
+ }
1288
+
1289
// Create a fragment from the fallback children, too.
1290
const fallbackChildFragment = (primaryChildFragment.sibling = createFiberFromFragment(
1291
nextFallbackChildren,
packages/react-reconciler/src/ReactFiberScheduler.js
+16
-14
@@ -1049,9 +1049,18 @@ function completeUnitOfWork(workInProgress: Fiber): Fiber | null {
1049
return null;
1050
}
1051
} else {
1052
- if (workInProgress.mode & ProfileMode) {
1052
+ if (enableProfilerTimer && workInProgress.mode & ProfileMode) {
1053
// Record the render duration for the fiber that errored.
1054
stopProfilerTimerIfRunningAndRecordDelta(workInProgress, false);
1055
+
1056
+ // Include the time spent working on failed children before continuing.
1057
+ let actualDuration = workInProgress.actualDuration;
1058
+ let child = workInProgress.child;
1059
+ while (child !== null) {
1060
+ actualDuration += child.actualDuration;
1061
+ child = child.sibling;
1062
+ }
1063
+ workInProgress.actualDuration = actualDuration;
1064
}
1065
1066
// This fiber did not complete because something threw. Pop values off
@@ -1076,19 +1085,6 @@ function completeUnitOfWork(workInProgress: Fiber): Fiber | null {
1085
ReactFiberInstrumentation.debugTool.onCompleteWork(workInProgress);
1086
}
1087
1079
- if (enableProfilerTimer) {
1080
- // Include the time spent working on failed children before continuing.
1081
- if (next.mode & ProfileMode) {
1082
- let actualDuration = next.actualDuration;
1083
- let child = next.child;
1084
- while (child !== null) {
1085
- actualDuration += child.actualDuration;
1086
- child = child.sibling;
1087
- }
1088
- next.actualDuration = actualDuration;
1089
- }
1090
- }
1091
-
1088
// If completing this work spawned new work, do that next. We'll come
1089
// back here again.
1090
// Since we're restarting, remove anything that is not a host effect
@@ -1314,6 +1310,12 @@ function renderRoot(root: FiberRoot, isYieldy: boolean): void {
1310
didFatal = true;
1311
onUncaughtError(thrownValue);
1312
} else {
1313
+ if (enableProfilerTimer && nextUnitOfWork.mode & ProfileMode) {
1314
+ // Record the time spent rendering before an error was thrown.
1315
+ // This avoids inaccurate Profiler durations in the case of a suspended render.
1316
+ stopProfilerTimerIfRunningAndRecordDelta(nextUnitOfWork, true);
1317
+ }
1318
+
1319
if (__DEV__) {
1320
// Reset global debug state
1321
// We assume this is defined in DEV
packages/react-reconciler/src/__tests__/ReactSuspensePlaceholder-test.internal.js
+245
-6
@@ -16,6 +16,9 @@ runPlaceholderTests('ReactSuspensePlaceholder (persistence)', () =>
16
);
17
18
function runPlaceholderTests(suiteLabel, loadReactNoop) {
19
+ let advanceTimeBy;
20
+ let mockNow;
21
+ let Profiler;
22
let React;
23
let ReactTestRenderer;
24
let ReactFeatureFlags;
@@ -27,13 +30,24 @@ function runPlaceholderTests(suiteLabel, loadReactNoop) {
30
describe(suiteLabel, () => {
31
beforeEach(() => {
32
jest.resetModules();
33
+
34
+ let currentTime = 0;
35
+ mockNow = jest.fn().mockImplementation(() => currentTime);
36
+ global.Date.now = mockNow;
37
+ advanceTimeBy = amount => {
38
+ currentTime += amount;
39
+ };
40
+
41
ReactFeatureFlags = require('shared/ReactFeatureFlags');
42
ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false;
43
+ ReactFeatureFlags.enableProfilerTimer = true;
44
ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = false;
45
React = require('react');
46
ReactTestRenderer = require('react-test-renderer');
47
+ ReactTestRenderer.unstable_setNowImplementation(mockNow);
48
ReactCache = require('react-cache');
49
50
+ Profiler = React.unstable_Profiler;
51
Suspense = React.Suspense;
52
53
TextResource = ReactCache.unstable_createResource(([text, ms = 0]) => {
@@ -83,15 +97,16 @@ function runPlaceholderTests(suiteLabel, loadReactNoop) {
97
textResourceShouldFail = false;
98
});
99
86
- function Text(props) {
87
- ReactTestRenderer.unstable_yield(props.text);
88
- return props.text;
100
+ function Text({fakeRenderDuration = 0, text = 'Text'}) {
101
+ advanceTimeBy(fakeRenderDuration);
102
+ ReactTestRenderer.unstable_yield(text);
103
+ return text;
104
}
105
91
- function AsyncText(props) {
92
- const text = props.text;
106
+ function AsyncText({fakeRenderDuration = 0, ms, text}) {
107
+ advanceTimeBy(fakeRenderDuration);
108
try {
94
- TextResource.read([props.text, props.ms]);
109
+ TextResource.read([text, ms]);
110
ReactTestRenderer.unstable_yield(text);
111
return text;
112
} catch (promise) {
@@ -215,5 +230,229 @@ function runPlaceholderTests(suiteLabel, loadReactNoop) {
230
// given a `hidden` prop.
231
expect(root).toMatchRenderedOutput('AB2C');
232
});
233
+
234
+ describe('profiler durations', () => {
235
+ let App;
236
+ let onRender;
237
+
238
+ beforeEach(() => {
239
+ // Order of parameters: id, phase, actualDuration, treeBaseDuration
240
+ onRender = jest.fn();
241
+
242
+ const Fallback = () => {
243
+ ReactTestRenderer.unstable_yield('Fallback');
244
+ advanceTimeBy(10);
245
+ return 'Loading...';
246
+ };
247
+
248
+ const Suspending = () => {
249
+ ReactTestRenderer.unstable_yield('Suspending');
250
+ advanceTimeBy(2);
251
+ return <AsyncText ms={1000} text="Loaded" fakeRenderDuration={1} />;
252
+ };
253
+
254
+ App = ({shouldSuspend, text = 'Text', textRenderDuration = 5}) => {
255
+ ReactTestRenderer.unstable_yield('App');
256
+ return (
257
+ <Profiler id="root" onRender={onRender}>
258
+ <Suspense maxDuration={500} fallback={<Fallback />}>
259
+ {shouldSuspend && <Suspending />}
260
+ <Text fakeRenderDuration={textRenderDuration} text={text} />
261
+ </Suspense>
262
+ </Profiler>
263
+ );
264
+ };
265
+ });
266
+
267
+ describe('when suspending during mount', () => {
268
+ it('properly accounts for base durations when a suspended times out in a sync tree', () => {
269
+ const root = ReactTestRenderer.create(<App shouldSuspend={true} />);
270
+ expect(root.toJSON()).toEqual(['Loading...']);
271
+ expect(onRender).toHaveBeenCalledTimes(1);
272
+
273
+ // Initial mount only shows the "Loading..." Fallback.
274
+ // The treeBaseDuration then should be 10ms spent rendering Fallback,
275
+ // but the actualDuration should also include the 8ms spent rendering the hidden tree.
276
+ expect(onRender.mock.calls[0][2]).toBe(18);
277
+ expect(onRender.mock.calls[0][3]).toBe(10);
278
+
279
+ jest.advanceTimersByTime(1000);
280
+
281
+ expect(root.toJSON()).toEqual(['Loaded', 'Text']);
282
+ expect(onRender).toHaveBeenCalledTimes(2);
283
+
284
+ // When the suspending data is resolved and our final UI is rendered,
285
+ // the baseDuration should only include the 1ms re-rendering AsyncText,
286
+ // but the treeBaseDuration should include the full 8ms spent in the tree.
287
+ expect(onRender.mock.calls[1][2]).toBe(1);
288
+ expect(onRender.mock.calls[1][3]).toBe(8);
289
+ });
290
+
291
+ it('properly accounts for base durations when a suspended times out in a concurrent tree', () => {
292
+ const root = ReactTestRenderer.create(<App shouldSuspend={true} />, {
293
+ unstable_isConcurrent: true,
294
+ });
295
+
296
+ expect(root).toFlushAndYield([
297
+ 'App',
298
+ 'Suspending',
299
+ 'Suspend! [Loaded]',
300
+ 'Text',
301
+ 'Fallback',
302
+ ]);
303
+ expect(root).toMatchRenderedOutput(null);
304
+
305
+ // Show the fallback UI.
306
+ jest.advanceTimersByTime(750);
307
+ expect(root).toMatchRenderedOutput('Loading...');
308
+ expect(onRender).toHaveBeenCalledTimes(1);
309
+
310
+ // Initial mount only shows the "Loading..." Fallback.
311
+ // The treeBaseDuration then should be 10ms spent rendering Fallback,
312
+ // but the actualDuration should also include the 8ms spent rendering the hidden tree.
313
+ expect(onRender.mock.calls[0][2]).toBe(18);
314
+ expect(onRender.mock.calls[0][3]).toBe(10);
315
+
316
+ // Resolve the pending promise.
317
+ jest.advanceTimersByTime(250);
318
+ expect(ReactTestRenderer).toHaveYielded([
319
+ 'Promise resolved [Loaded]',
320
+ ]);
321
+ expect(root).toFlushAndYield(['Suspending', 'Loaded', 'Text']);
322
+ expect(root).toMatchRenderedOutput('LoadedText');
323
+ expect(onRender).toHaveBeenCalledTimes(2);
324
+
325
+ // When the suspending data is resolved and our final UI is rendered,
326
+ // both times should include the 8ms re-rendering Suspending and AsyncText.
327
+ expect(onRender.mock.calls[1][2]).toBe(8);
328
+ expect(onRender.mock.calls[1][3]).toBe(8);
329
+ });
330
+ });
331
+
332
+ describe('when suspending during update', () => {
333
+ it('properly accounts for base durations when a suspended times out in a sync tree', () => {
334
+ const root = ReactTestRenderer.create(
335
+ <App shouldSuspend={false} textRenderDuration={5} />,
336
+ );
337
+ expect(root.toJSON()).toEqual('Text');
338
+ expect(onRender).toHaveBeenCalledTimes(1);
339
+
340
+ // Initial mount only shows the "Text" text.
341
+ // It should take 5ms to render.
342
+ expect(onRender.mock.calls[0][2]).toBe(5);
343
+ expect(onRender.mock.calls[0][3]).toBe(5);
344
+
345
+ root.update(<App shouldSuspend={true} textRenderDuration={5} />);
346
+ expect(root.toJSON()).toEqual(['Loading...']);
347
+ expect(onRender).toHaveBeenCalledTimes(2);
348
+
349
+ // The suspense update should only show the "Loading..." Fallback.
350
+ // Both durations should include 10ms spent rendering Fallback
351
+ // plus the 8ms rendering the (hidden) components.
352
+ expect(onRender.mock.calls[1][2]).toBe(18);
353
+ expect(onRender.mock.calls[1][3]).toBe(18);
354
+
355
+ root.update(
356
+ <App shouldSuspend={true} text="New" textRenderDuration={6} />,
357
+ );
358
+ expect(root.toJSON()).toEqual(['Loading...']);
359
+ expect(onRender).toHaveBeenCalledTimes(3);
360
+
361
+ // If we force another update while still timed out,
362
+ // but this time the Text component took 1ms longer to render.
363
+ // This should impact both actualDuration and treeBaseDuration.
364
+ expect(onRender.mock.calls[2][2]).toBe(19);
365
+ expect(onRender.mock.calls[2][3]).toBe(19);
366
+
367
+ jest.advanceTimersByTime(1000);
368
+
369
+ // TODO Change expected onRender count to 4.
370
+ // At the moment, every time we suspended while rendering will cause a commit.
371
+ // This will probably change in the future, but that's why there are two new ones.
372
+ expect(root.toJSON()).toEqual(['Loaded', 'New']);
373
+ expect(onRender).toHaveBeenCalledTimes(5);
374
+
375
+ // When the suspending data is resolved and our final UI is rendered,
376
+ // the baseDuration should only include the 1ms re-rendering AsyncText,
377
+ // but the treeBaseDuration should include the full 9ms spent in the tree.
378
+ expect(onRender.mock.calls[3][2]).toBe(1);
379
+ expect(onRender.mock.calls[3][3]).toBe(9);
380
+
381
+ // TODO Remove these assertions once this commit is gone.
382
+ // For now, there was no actual work done during this commit; see above comment.
383
+ expect(onRender.mock.calls[4][2]).toBe(0);
384
+ expect(onRender.mock.calls[4][3]).toBe(9);
385
+ });
386
+
387
+ it('properly accounts for base durations when a suspended times out in a concurrent tree', () => {
388
+ const root = ReactTestRenderer.create(
389
+ <App shouldSuspend={false} textRenderDuration={5} />,
390
+ {
391
+ unstable_isConcurrent: true,
392
+ },
393
+ );
394
+
395
+ expect(root).toFlushAndYield(['App', 'Text']);
396
+ expect(root).toMatchRenderedOutput('Text');
397
+ expect(onRender).toHaveBeenCalledTimes(1);
398
+
399
+ // Initial mount only shows the "Text" text.
400
+ // It should take 5ms to render.
401
+ expect(onRender.mock.calls[0][2]).toBe(5);
402
+ expect(onRender.mock.calls[0][3]).toBe(5);
403
+
404
+ root.update(<App shouldSuspend={true} textRenderDuration={5} />);
405
+ expect(root).toFlushAndYield([
406
+ 'App',
407
+ 'Suspending',
408
+ 'Suspend! [Loaded]',
409
+ 'Text',
410
+ 'Fallback',
411
+ ]);
412
+ expect(root).toMatchRenderedOutput('Text');
413
+
414
+ // Show the fallback UI.
415
+ jest.advanceTimersByTime(750);
416
+ expect(root).toMatchRenderedOutput('Loading...');
417
+ expect(onRender).toHaveBeenCalledTimes(2);
418
+
419
+ // The suspense update should only show the "Loading..." Fallback.
420
+ // The actual duration should include 10ms spent rendering Fallback,
421
+ // plus the 8ms render all of the hidden, suspended subtree.
422
+ // But the tree base duration should only include 10ms spent rendering Fallback,
423
+ // plus the 5ms rendering the previously committed version of the hidden tree.
424
+ expect(onRender.mock.calls[1][2]).toBe(18);
425
+ expect(onRender.mock.calls[1][3]).toBe(15);
426
+
427
+ // Update again while timed out.
428
+ root.update(
429
+ <App shouldSuspend={true} text="New" textRenderDuration={6} />,
430
+ );
431
+ expect(root).toFlushAndYield([
432
+ 'App',
433
+ 'Suspending',
434
+ 'Suspend! [Loaded]',
435
+ 'New',
436
+ 'Fallback',
437
+ ]);
438
+ expect(root).toMatchRenderedOutput('Loading...');
439
+ expect(onRender).toHaveBeenCalledTimes(2);
440
+
441
+ // Resolve the pending promise.
442
+ jest.advanceTimersByTime(250);
443
+ expect(ReactTestRenderer).toHaveYielded([
444
+ 'Promise resolved [Loaded]',
445
+ ]);
446
+ expect(root).toFlushAndYield(['App', 'Suspending', 'Loaded', 'New']);
447
+ expect(onRender).toHaveBeenCalledTimes(3);
448
+
449
+ // When the suspending data is resolved and our final UI is rendered,
450
+ // both times should include the 6ms rendering Text,
451
+ // the 2ms rendering Suspending, and the 1ms rendering AsyncText.
452
+ expect(onRender.mock.calls[2][2]).toBe(9);
453
+ expect(onRender.mock.calls[2][3]).toBe(9);
454
+ });
455
+ });
456
+ });
457
});
458
}