From fcff9c57bc41f58e8802016b4dbc0a7b72cc63ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Fri, 12 Jul 2019 15:55:47 -0700 Subject: [PATCH] Add tail="hidden" option to SuspenseList (#16024) * Move misaligned comment * Add tail="hidden" option * isShowingAnyFallbacks -> findFirstSuspended * We can't reset Placement tags or we'll forget to insert them * Delete hasSuspendedChildrenAndNewContent optimization --- packages/react-reconciler/src/ReactFiber.js | 7 +- .../src/ReactFiberBeginWork.js | 14 +- .../src/ReactFiberCompleteWork.js | 214 +++++++++--------- .../src/ReactFiberSuspenseComponent.js | 28 ++- .../ReactSuspenseList-test.internal.js | 103 ++++++++- 5 files changed, 232 insertions(+), 134 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiber.js b/packages/react-reconciler/src/ReactFiber.js index 4821f4205e..73a3c4c179 100644 --- a/packages/react-reconciler/src/ReactFiber.js +++ b/packages/react-reconciler/src/ReactFiber.js @@ -27,7 +27,7 @@ import type {ReactEventComponentInstance} from 'shared/ReactTypes'; import invariant from 'shared/invariant'; import warningWithoutStack from 'shared/warningWithoutStack'; import {enableProfilerTimer, enableFlareAPI} from 'shared/ReactFeatureFlags'; -import {NoEffect} from 'shared/ReactSideEffectTags'; +import {NoEffect, Placement} from 'shared/ReactSideEffectTags'; import {ConcurrentRoot, BatchedRoot} from 'shared/ReactRootTags'; import { IndeterminateComponent, @@ -494,8 +494,9 @@ export function resetWorkInProgress( // We assume pendingProps, index, key, ref, return are still untouched to // avoid doing another reconciliation. - // Reset the effect tag. - workInProgress.effectTag = NoEffect; + // Reset the effect tag but keep any Placement tags, since that's something + // that child fiber is setting, not the reconciliation. + workInProgress.effectTag &= Placement; // The effect list is no longer valid. workInProgress.nextEffect = null; diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index 0bf2484775..3c62caf099 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -125,7 +125,7 @@ import { addSubtreeSuspenseContext, setShallowSuspenseContext, } from './ReactFiberSuspenseContext'; -import {isShowingAnyFallbacks} from './ReactFiberSuspenseComponent'; +import {findFirstSuspended} from './ReactFiberSuspenseComponent'; import { pushProvider, propagateContextChange, @@ -2000,7 +2000,7 @@ function findLastContentRow(firstChild: null | Fiber): null | Fiber { while (row !== null) { let currentRow = row.alternate; // New rows can't be content rows. - if (currentRow !== null && !isShowingAnyFallbacks(currentRow)) { + if (currentRow !== null && findFirstSuspended(currentRow) === null) { lastContentRow = row; } row = row.sibling; @@ -2072,12 +2072,12 @@ function validateTailOptions( ) { if (__DEV__) { if (tailMode !== undefined && !didWarnAboutTailOptions[tailMode]) { - if (tailMode !== 'collapsed') { + if (tailMode !== 'collapsed' && tailMode !== 'hidden') { didWarnAboutTailOptions[tailMode] = true; warning( false, '"%s" is not a supported value for tail on . ' + - 'Did you mean "collapsed"?', + 'Did you mean "collapsed" or "hidden"?', tailMode, ); } else if (revealOrder !== 'forwards' && revealOrder !== 'backwards') { @@ -2242,10 +2242,10 @@ function updateSuspenseListComponent( pushSuspenseContext(workInProgress, suspenseContext); if ((workInProgress.mode & BatchedMode) === NoMode) { - workInProgress.memoizedState = null; - } else { // Outside of batched mode, SuspenseList doesn't work so we just // use make it a noop by treating it as the default revealOrder. + workInProgress.memoizedState = null; + } else { switch (revealOrder) { case 'forwards': { let lastContentRow = findLastContentRow(workInProgress.child); @@ -2281,7 +2281,7 @@ function updateSuspenseListComponent( while (row !== null) { let currentRow = row.alternate; // New rows can't be content rows. - if (currentRow !== null && !isShowingAnyFallbacks(currentRow)) { + if (currentRow !== null && findFirstSuspended(currentRow) === null) { // This is the beginning of the main content. workInProgress.child = row; break; diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.js b/packages/react-reconciler/src/ReactFiberCompleteWork.js index 22df199a47..faa6548120 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.js @@ -92,7 +92,7 @@ import { ForceSuspenseFallback, setDefaultShallowSuspenseContext, } from './ReactFiberSuspenseContext'; -import {isShowingAnyFallbacks} from './ReactFiberSuspenseComponent'; +import {findFirstSuspended} from './ReactFiberSuspenseComponent'; import { isContextProvider as isLegacyContextProvider, popContext as popLegacyContext, @@ -537,6 +537,32 @@ function cutOffTailIfNeeded( hasRenderedATailFallback: boolean, ) { switch (renderState.tailMode) { + case 'hidden': { + // Any insertions at the end of the tail list after this point + // should be invisible. If there are already mounted boundaries + // anything before them are not considered for collapsing. + // Therefore we need to go through the whole tail to find if + // there are any. + let tailNode = renderState.tail; + let lastTailNode = null; + while (tailNode !== null) { + if (tailNode.alternate !== null) { + lastTailNode = tailNode; + } + tailNode = tailNode.sibling; + } + // Next we're simply going to delete all insertions after the + // last rendered item. + if (lastTailNode === null) { + // All remaining items in the tail are insertions. + renderState.tail = null; + } else { + // Detach the insertion after the last node that was already + // inserted. + lastTailNode.sibling = null; + } + break; + } case 'collapsed': { // Any insertions at the end of the tail list after this point // should be invisible. If there are already mounted boundaries @@ -572,89 +598,6 @@ function cutOffTailIfNeeded( } } -// Note this, might mutate the workInProgress passed in. -function hasSuspendedChildrenAndNewContent( - workInProgress: Fiber, - firstChild: null | Fiber, -): boolean { - // Traversal to see if any of the immediately nested Suspense boundaries - // are in their fallback states. I.e. something suspended in them. - // And if some of them have new content that wasn't already visible. - let hasSuspendedBoundaries = false; - let hasNewContent = false; - - let node = firstChild; - while (node !== null) { - // TODO: Hidden subtrees should not be considered. - if (node.tag === SuspenseComponent) { - const state: SuspenseState | null = node.memoizedState; - const isShowingFallback = state !== null; - if (isShowingFallback) { - // Tag the parent fiber as having suspended boundaries. - if (!hasSuspendedBoundaries) { - workInProgress.effectTag |= DidCapture; - } - - hasSuspendedBoundaries = true; - - if (node.updateQueue !== null) { - // If this is a newly suspended tree, it might not get committed as - // part of the second pass. In that case nothing will subscribe to - // its thennables. Instead, we'll transfer its thennables to the - // SuspenseList so that it can retry if they resolve. - // There might be multiple of these in the list but since we're - // going to wait for all of them anyway, it doesn't really matter - // which ones gets to ping. In theory we could get clever and keep - // track of how many dependencies remain but it gets tricky because - // in the meantime, we can add/remove/change items and dependencies. - // We might bail out of the loop before finding any but that - // doesn't matter since that means that the other boundaries that - // we did find already has their listeners attached. - workInProgress.updateQueue = node.updateQueue; - workInProgress.effectTag |= Update; - } - } else { - const current = node.alternate; - const wasNotShowingContent = - current === null || current.memoizedState !== null; - if (wasNotShowingContent) { - hasNewContent = true; - } - } - if (hasSuspendedBoundaries && hasNewContent) { - return true; - } - } else { - // TODO: We can probably just use the information from the list and not - // drill into its children just like if it was a Suspense boundary. - if (node.tag === SuspenseListComponent && node.updateQueue !== null) { - // If there's a nested SuspenseList, we might have transferred - // the thennables set to it already so we must get it from there. - workInProgress.updateQueue = node.updateQueue; - workInProgress.effectTag |= Update; - } - - if (node.child !== null) { - node.child.return = node; - node = node.child; - continue; - } - } - if (node === workInProgress) { - return false; - } - while (node.sibling === null) { - if (node.return === null || node.return === workInProgress) { - return false; - } - node = node.return; - } - node.sibling.return = node.return; - node = node.sibling; - } - return false; -} - function completeWork( current: Fiber | null, workInProgress: Fiber, @@ -988,7 +931,7 @@ function completeWork( if (!didSuspendAlready) { // This is the first pass. We need to figure out if anything is still // suspended in the rendered set. - const renderedChildren = workInProgress.child; + // If new content unsuspended, but there's still some content that // didn't. Then we need to do a second pass that forces everything // to keep showing their fallbacks. @@ -996,47 +939,93 @@ function completeWork( // We might be suspended if something in this render pass suspended, or // something in the previous committed pass suspended. Otherwise, // there's no chance so we can skip the expensive call to - // hasSuspendedChildrenAndNewContent. + // findFirstSuspended. let cannotBeSuspended = renderHasNotSuspendedYet() && (current === null || (current.effectTag & DidCapture) === NoEffect); - let needsRerender = - !cannotBeSuspended && - hasSuspendedChildrenAndNewContent(workInProgress, renderedChildren); - if (needsRerender) { - // Rerender the whole list, but this time, we'll force fallbacks - // to stay in place. - // Reset the effect list before doing the second pass since that's now invalid. - workInProgress.firstEffect = workInProgress.lastEffect = null; - // Reset the child fibers to their original state. - resetChildFibers(workInProgress, renderExpirationTime); + if (!cannotBeSuspended) { + let row = workInProgress.child; + while (row !== null) { + let suspended = findFirstSuspended(row); + if (suspended !== null) { + didSuspendAlready = true; + workInProgress.effectTag |= DidCapture; + cutOffTailIfNeeded(renderState, false); - // Set up the Suspense Context to force suspense and immediately - // rerender the children. - pushSuspenseContext( - workInProgress, - setShallowSuspenseContext( - suspenseStackCursor.current, - ForceSuspenseFallback, - ), - ); - return workInProgress.child; + // If this is a newly suspended tree, it might not get committed as + // part of the second pass. In that case nothing will subscribe to + // its thennables. Instead, we'll transfer its thennables to the + // SuspenseList so that it can retry if they resolve. + // There might be multiple of these in the list but since we're + // going to wait for all of them anyway, it doesn't really matter + // which ones gets to ping. In theory we could get clever and keep + // track of how many dependencies remain but it gets tricky because + // in the meantime, we can add/remove/change items and dependencies. + // We might bail out of the loop before finding any but that + // doesn't matter since that means that the other boundaries that + // we did find already has their listeners attached. + let newThennables = suspended.updateQueue; + if (newThennables !== null) { + workInProgress.updateQueue = newThennables; + workInProgress.effectTag |= Update; + } + + // Rerender the whole list, but this time, we'll force fallbacks + // to stay in place. + // Reset the effect list before doing the second pass since that's now invalid. + workInProgress.firstEffect = workInProgress.lastEffect = null; + // Reset the child fibers to their original state. + resetChildFibers(workInProgress, renderExpirationTime); + + // Set up the Suspense Context to force suspense and immediately + // rerender the children. + pushSuspenseContext( + workInProgress, + setShallowSuspenseContext( + suspenseStackCursor.current, + ForceSuspenseFallback, + ), + ); + return workInProgress.child; + } + row = row.sibling; + } } - // hasSuspendedChildrenAndNewContent could've set didSuspendAlready - didSuspendAlready = - (workInProgress.effectTag & DidCapture) !== NoEffect; - } - if (didSuspendAlready) { + } else { cutOffTailIfNeeded(renderState, false); } // Next we're going to render the tail. } else { // Append the rendered row to the child list. if (!didSuspendAlready) { - if (isShowingAnyFallbacks(renderedTail)) { + let suspended = findFirstSuspended(renderedTail); + if (suspended !== null) { workInProgress.effectTag |= DidCapture; didSuspendAlready = true; cutOffTailIfNeeded(renderState, true); + // This might have been modified. + if ( + renderState.tail === null && + renderState.tailMode === 'hidden' + ) { + // We need to delete the row we just rendered. + // Ensure we transfer the update queue to the parent. + let newThennables = suspended.updateQueue; + if (newThennables !== null) { + workInProgress.updateQueue = newThennables; + workInProgress.effectTag |= Update; + } + // Reset the effect list to what it w as before we rendered this + // child. The nested children have already appended themselves. + let lastEffect = (workInProgress.lastEffect = + renderState.lastEffect); + // Remove any effects that were appended after this point. + if (lastEffect !== null) { + lastEffect.nextEffect = null; + } + // We're done. + return null; + } } else if ( now() > renderState.tailExpiration && renderExpirationTime > Never @@ -1093,6 +1082,7 @@ function completeWork( let next = renderState.tail; renderState.rendering = next; renderState.tail = next.sibling; + renderState.lastEffect = workInProgress.lastEffect; next.sibling = null; // Restore the context. diff --git a/packages/react-reconciler/src/ReactFiberSuspenseComponent.js b/packages/react-reconciler/src/ReactFiberSuspenseComponent.js index 317cbf20a3..2440337b2e 100644 --- a/packages/react-reconciler/src/ReactFiberSuspenseComponent.js +++ b/packages/react-reconciler/src/ReactFiberSuspenseComponent.js @@ -8,13 +8,14 @@ */ import type {Fiber} from './ReactFiber'; -import {SuspenseComponent} from 'shared/ReactWorkTags'; +import {SuspenseComponent, SuspenseListComponent} from 'shared/ReactWorkTags'; +import {NoEffect, DidCapture} from 'shared/ReactSideEffectTags'; // TODO: This is now an empty object. Should we switch this to a boolean? // Alternatively we can make this use an effect tag similar to SuspenseList. export type SuspenseState = {||}; -export type SuspenseListTailMode = 'collapsed' | void; +export type SuspenseListTailMode = 'collapsed' | 'hidden' | void; export type SuspenseListRenderState = {| isBackwards: boolean, @@ -28,6 +29,9 @@ export type SuspenseListRenderState = {| tailExpiration: number, // Tail insertions setting. tailMode: SuspenseListTailMode, + // Last Effect before we rendered the "rendering" item. + // Used to remove new effects added by the rendered item. + lastEffect: null | Fiber, |}; export function shouldCaptureSuspense( @@ -58,13 +62,23 @@ export function shouldCaptureSuspense( return true; } -export function isShowingAnyFallbacks(row: Fiber): boolean { +export function findFirstSuspended(row: Fiber): null | Fiber { let node = row; while (node !== null) { if (node.tag === SuspenseComponent) { const state: SuspenseState | null = node.memoizedState; if (state !== null) { - return true; + return node; + } + } else if ( + node.tag === SuspenseListComponent && + // revealOrder undefined can't be trusted because it don't + // keep track of whether it suspended or not. + node.memoizedProps.revealOrder !== undefined + ) { + let didSuspend = (node.effectTag & DidCapture) !== NoEffect; + if (didSuspend) { + return node; } } else if (node.child !== null) { node.child.return = node; @@ -72,16 +86,16 @@ export function isShowingAnyFallbacks(row: Fiber): boolean { continue; } if (node === row) { - return false; + return null; } while (node.sibling === null) { if (node.return === null || node.return === row) { - return false; + return null; } node = node.return; } node.sibling.return = node.return; node = node.sibling; } - return false; + return null; } diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.internal.js b/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.internal.js index d8226c32e5..94fc54601c 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.internal.js @@ -640,6 +640,9 @@ describe('ReactSuspenseList', () => { 'Loading B', 'Suspend! [C]', 'Loading C', + 'A', + 'Loading B', + 'Loading C', ]); // This will suspend, since the boundaries are avoided. Give them @@ -859,6 +862,10 @@ describe('ReactSuspenseList', () => { 'Suspend! [C]', 'Loading C', 'D', + 'Loading A', + 'B', + 'Loading C', + 'D', 'Loading E', 'Loading F', ]); @@ -1013,6 +1020,16 @@ describe('ReactSuspenseList', () => { 'E', 'Suspend! [F]', 'Loading F', + 'Suspend! [A]', + 'Loading A', + 'Suspend! [B]', + 'Loading B', + 'C', + 'Suspend! [D]', + 'Loading D', + 'E', + 'Suspend! [F]', + 'Loading F', ]); // This will suspend, since the boundaries are avoided. Give them @@ -1251,7 +1268,7 @@ describe('ReactSuspenseList', () => { expect(() => Scheduler.unstable_flushAll()).toWarnDev([ 'Warning: "collapse" is not a supported value for tail on ' + - '. Did you mean "collapsed"?' + + '. Did you mean "collapsed" or "hidden"?' + '\n in SuspenseList (at **)' + '\n in Foo (at **)', ]); @@ -1392,6 +1409,10 @@ describe('ReactSuspenseList', () => { 'Suspend! [C]', 'Loading C', 'D', + 'A', + 'Loading B', + 'Loading C', + 'D', 'Loading E', ]); @@ -1516,6 +1537,10 @@ describe('ReactSuspenseList', () => { 'Suspend! [E]', 'Loading E', 'F', + 'C', + 'Loading D', + 'Loading E', + 'F', 'Loading B', ]); @@ -1530,15 +1555,15 @@ describe('ReactSuspenseList', () => { , ); - await E.resolve(); + await D.resolve(); - expect(Scheduler).toFlushAndYield(['Suspend! [D]', 'E']); + expect(Scheduler).toFlushAndYield(['D', 'Suspend! [E]']); // Incremental loading is suspended. jest.advanceTimersByTime(500); - // Even though E is unsuspended, it's still in loading state because - // it is blocked by D. + // Even though D is unsuspended, it's still in loading state because + // it is blocked by E. expect(ReactNoop).toMatchRenderedOutput( Loading B @@ -1650,6 +1675,11 @@ describe('ReactSuspenseList', () => { 'Loading C', 'Suspend! [D]', 'Loading D', + 'A', + 'Loading B', + 'Loading C', + 'Suspend! [D]', + 'Loading D', 'Loading E', ]); @@ -1733,4 +1763,67 @@ describe('ReactSuspenseList', () => { , ); }); + + it('only shows no initial loading state "hidden" tail insertions', async () => { + let A = createAsyncText('A'); + let B = createAsyncText('B'); + let C = createAsyncText('C'); + + function Foo() { + return ( + + }> + + + }> + + + }> + + + + ); + } + + ReactNoop.render(); + + expect(Scheduler).toFlushAndYield(['Suspend! [A]', 'Loading A']); + + expect(ReactNoop).toMatchRenderedOutput(null); + + await A.resolve(); + + expect(Scheduler).toFlushAndYield(['A', 'Suspend! [B]', 'Loading B']); + + // Incremental loading is suspended. + jest.advanceTimersByTime(500); + + expect(ReactNoop).toMatchRenderedOutput(A); + + await B.resolve(); + + expect(Scheduler).toFlushAndYield(['B', 'Suspend! [C]', 'Loading C']); + + // Incremental loading is suspended. + jest.advanceTimersByTime(500); + + expect(ReactNoop).toMatchRenderedOutput( + + A + B + , + ); + + await C.resolve(); + + expect(Scheduler).toFlushAndYield(['C']); + + expect(ReactNoop).toMatchRenderedOutput( + + A + B + C + , + ); + }); });