@samitouri / QOS-React / commits / 3ff2c7ccd4

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 }