Add tail="collapsed" option to SuspenseList (#16007)

* Add tail="collapsed" option

* Fix issue with tail exceeding the CPU time limit

We used to assume that this didn't suspend but this branch happens in
both cases. This fixes it so that we first check if we suspended.

Now we can fix the tail so that it always render an additional fallback
in this scenario.
This commit is contained in:
Sebastian Markbåge
2019-07-01 19:56:34 -07:00
committed by GitHub
parent 6ef5f30aa2
commit 7ca2c67f9e
4 changed files with 659 additions and 4 deletions
+39
View File
@@ -14,6 +14,7 @@ import type {ExpirationTime} from './ReactFiberExpirationTime';
import type {
SuspenseState,
SuspenseListRenderState,
SuspenseListTailMode,
} from './ReactFiberSuspenseComponent';
import type {SuspenseContext} from './ReactFiberSuspenseContext';
@@ -188,6 +189,7 @@ let didWarnAboutFunctionRefs;
export let didWarnAboutReassigningProps;
let didWarnAboutMaxDuration;
let didWarnAboutRevealOrder;
let didWarnAboutTailOptions;
if (__DEV__) {
didWarnAboutBadClass = {};
@@ -198,6 +200,7 @@ if (__DEV__) {
didWarnAboutReassigningProps = false;
didWarnAboutMaxDuration = false;
didWarnAboutRevealOrder = {};
didWarnAboutTailOptions = {};
}
export function reconcileChildren(
@@ -2063,11 +2066,40 @@ function validateRevealOrder(revealOrder: SuspenseListRevealOrder) {
}
}
function validateTailOptions(
tailMode: SuspenseListTailMode,
revealOrder: SuspenseListRevealOrder,
) {
if (__DEV__) {
if (tailMode !== undefined && !didWarnAboutTailOptions[tailMode]) {
if (tailMode !== 'collapsed') {
didWarnAboutTailOptions[tailMode] = true;
warning(
false,
'"%s" is not a supported value for tail on <SuspenseList />. ' +
'Did you mean "collapsed"?',
tailMode,
);
} else if (revealOrder !== 'forwards' && revealOrder !== 'backwards') {
didWarnAboutTailOptions[tailMode] = true;
warning(
false,
'<SuspenseList tail="%s" /> is only valid if revealOrder is ' +
'"forwards" or "backwards". ' +
'Did you mean to specify revealOrder="forwards"?',
tailMode,
);
}
}
}
}
function initSuspenseListRenderState(
workInProgress: Fiber,
isBackwards: boolean,
tail: null | Fiber,
lastContentRow: null | Fiber,
tailMode: SuspenseListTailMode,
): void {
let renderState: null | SuspenseListRenderState =
workInProgress.memoizedState;
@@ -2078,6 +2110,7 @@ function initSuspenseListRenderState(
last: lastContentRow,
tail: tail,
tailExpiration: 0,
tailMode: tailMode,
};
} else {
// We can reuse the existing object from previous renders.
@@ -2086,6 +2119,7 @@ function initSuspenseListRenderState(
renderState.last = lastContentRow;
renderState.tail = tail;
renderState.tailExpiration = 0;
renderState.tailMode = tailMode;
}
}
@@ -2103,9 +2137,11 @@ function updateSuspenseListComponent(
) {
const nextProps = workInProgress.pendingProps;
const revealOrder: SuspenseListRevealOrder = nextProps.revealOrder;
const tailMode: SuspenseListTailMode = nextProps.tail;
const newChildren = nextProps.children;
validateRevealOrder(revealOrder);
validateTailOptions(tailMode, revealOrder);
reconcileChildren(current, workInProgress, newChildren, renderExpirationTime);
@@ -2163,6 +2199,7 @@ function updateSuspenseListComponent(
false, // isBackwards
tail,
lastContentRow,
tailMode,
);
break;
}
@@ -2193,6 +2230,7 @@ function updateSuspenseListComponent(
true, // isBackwards
tail,
null, // last
tailMode,
);
break;
}
@@ -2202,6 +2240,7 @@ function updateSuspenseListComponent(
false, // isBackwards
null, // tail
null, // last
undefined,
);
break;
}
+51 -4
View File
@@ -537,6 +537,46 @@ if (supportsMutation) {
};
}
function cutOffTailIfNeeded(
renderState: SuspenseListRenderState,
hasRenderedATailFallback: boolean,
) {
switch (renderState.tailMode) {
case 'collapsed': {
// 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.
if (!hasRenderedATailFallback && renderState.tail !== null) {
// We suspended during the head. We want to show at least one
// row at the tail. So we'll keep on and cut off the rest.
renderState.tail.sibling = null;
} else {
renderState.tail = null;
}
} else {
// Detach the insertion after the last node that was already
// inserted.
lastTailNode.sibling = null;
}
break;
}
}
}
// Note this, might mutate the workInProgress passed in.
function hasSuspendedChildrenAndNewContent(
workInProgress: Fiber,
@@ -991,11 +1031,18 @@ function completeWork(
didSuspendAlready =
(workInProgress.effectTag & DidCapture) !== NoEffect;
}
if (didSuspendAlready) {
cutOffTailIfNeeded(renderState, false);
}
// Next we're going to render the tail.
} else {
// Append the rendered row to the child list.
if (!didSuspendAlready) {
if (
if (isShowingAnyFallbacks(renderedTail)) {
workInProgress.effectTag |= DidCapture;
didSuspendAlready = true;
cutOffTailIfNeeded(renderState, true);
} else if (
now() > renderState.tailExpiration &&
renderExpirationTime > Never
) {
@@ -1004,6 +1051,9 @@ function completeWork(
// The assumption is that this is usually faster.
workInProgress.effectTag |= DidCapture;
didSuspendAlready = true;
cutOffTailIfNeeded(renderState, false);
// Since nothing actually suspended, there will nothing to ping this
// to get it started back up to attempt the next item. If we can show
// them, then they really have the same priority as this render.
@@ -1015,9 +1065,6 @@ function completeWork(
if (enableSchedulerTracing) {
markSpawnedWork(nextPriority);
}
} else if (isShowingAnyFallbacks(renderedTail)) {
workInProgress.effectTag |= DidCapture;
didSuspendAlready = true;
}
}
if (renderState.isBackwards) {
@@ -14,6 +14,8 @@ import {SuspenseComponent} from 'shared/ReactWorkTags';
// Alternatively we can make this use an effect tag similar to SuspenseList.
export type SuspenseState = {||};
export type SuspenseListTailMode = 'collapsed' | void;
export type SuspenseListRenderState = {|
isBackwards: boolean,
// The currently rendering tail row.
@@ -24,6 +26,8 @@ export type SuspenseListRenderState = {|
tail: null | Fiber,
// The absolute time in ms that we'll expire the tail rendering.
tailExpiration: number,
// Tail insertions setting.
tailMode: SuspenseListTailMode,
|};
export function shouldCaptureSuspense(
@@ -1088,4 +1088,569 @@ describe('ReactSuspenseList', () => {
</Fragment>,
);
});
it('only shows one loading state at a time for "collapsed" tail insertions', async () => {
let A = createAsyncText('A');
let B = createAsyncText('B');
let C = createAsyncText('C');
function Foo() {
return (
<SuspenseList revealOrder="forwards" tail="collapsed">
<Suspense fallback={<Text text="Loading A" />}>
<A />
</Suspense>
<Suspense fallback={<Text text="Loading B" />}>
<B />
</Suspense>
<Suspense fallback={<Text text="Loading C" />}>
<C />
</Suspense>
</SuspenseList>
);
}
ReactNoop.render(<Foo />);
expect(Scheduler).toFlushAndYield(['Suspend! [A]', 'Loading A']);
expect(ReactNoop).toMatchRenderedOutput(<span>Loading A</span>);
await A.resolve();
expect(Scheduler).toFlushAndYield(['A', 'Suspend! [B]', 'Loading B']);
// Incremental loading is suspended.
jest.advanceTimersByTime(500);
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>A</span>
<span>Loading B</span>
</Fragment>,
);
await B.resolve();
expect(Scheduler).toFlushAndYield(['B', 'Suspend! [C]', 'Loading C']);
// Incremental loading is suspended.
jest.advanceTimersByTime(500);
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>A</span>
<span>B</span>
<span>Loading C</span>
</Fragment>,
);
await C.resolve();
expect(Scheduler).toFlushAndYield(['C']);
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>A</span>
<span>B</span>
<span>C</span>
</Fragment>,
);
});
it('warns if an unsupported tail option is used', () => {
function Foo() {
return (
<SuspenseList revealOrder="forwards" tail="collapse">
<Suspense fallback="Loading">Content</Suspense>
</SuspenseList>
);
}
ReactNoop.render(<Foo />);
expect(() => Scheduler.unstable_flushAll()).toWarnDev([
'Warning: "collapse" is not a supported value for tail on ' +
'<SuspenseList />. Did you mean "collapsed"?' +
'\n in SuspenseList (at **)' +
'\n in Foo (at **)',
]);
});
it('warns if a tail option is used with "together"', () => {
function Foo() {
return (
<SuspenseList revealOrder="together" tail="collapsed">
<Suspense fallback="Loading">Content</Suspense>
</SuspenseList>
);
}
ReactNoop.render(<Foo />);
expect(() => Scheduler.unstable_flushAll()).toWarnDev([
'Warning: <SuspenseList tail="collapsed" /> is only valid if ' +
'revealOrder is "forwards" or "backwards". ' +
'Did you mean to specify revealOrder="forwards"?' +
'\n in SuspenseList (at **)' +
'\n in Foo (at **)',
]);
});
it('renders one "collapsed" fallback even if CPU time elapsed', async () => {
function Foo() {
return (
<SuspenseList revealOrder="forwards" tail="collapsed">
<Suspense fallback={<Text text="Loading A" />}>
<Text text="A" />
</Suspense>
<Suspense fallback={<Text text="Loading B" />}>
<Text text="B" />
</Suspense>
<Suspense fallback={<Text text="Loading C" />}>
<Text text="C" />
</Suspense>
<Suspense fallback={<Text text="Loading D" />}>
<Text text="D" />
</Suspense>
</SuspenseList>
);
}
// This render is only CPU bound. Nothing suspends.
ReactNoop.render(<Foo />);
expect(Scheduler).toFlushAndYieldThrough(['A']);
Scheduler.unstable_advanceTime(300);
jest.advanceTimersByTime(300);
expect(Scheduler).toFlushAndYieldThrough(['B']);
Scheduler.unstable_advanceTime(300);
jest.advanceTimersByTime(300);
// We've still not been able to show anything on the screen even though
// we have two items ready.
expect(ReactNoop).toMatchRenderedOutput(null);
// Time has now elapsed for so long that we're just going to give up
// rendering the rest of the content. So that we can at least show
// something.
expect(Scheduler).toFlushAndYieldThrough([
'Loading C',
'C', // I'll flush through into the next render so that the first commits.
]);
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>A</span>
<span>B</span>
<span>Loading C</span>
</Fragment>,
);
// Then we do a second pass to commit the last two items.
expect(Scheduler).toFlushAndYield(['D']);
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>A</span>
<span>B</span>
<span>C</span>
<span>D</span>
</Fragment>,
);
});
it('adding to the middle does not collapse insertions (forwards)', async () => {
let A = createAsyncText('A');
let B = createAsyncText('B');
let C = createAsyncText('C');
let D = createAsyncText('D');
let E = createAsyncText('E');
let F = createAsyncText('F');
function Foo({items}) {
return (
<SuspenseList revealOrder="forwards" tail="collapsed">
{items.map(([key, Component]) => (
<Suspense key={key} fallback={<Text text={'Loading ' + key} />}>
<Component />
</Suspense>
))}
</SuspenseList>
);
}
ReactNoop.render(<Foo items={[['A', A], ['D', D]]} />);
await A.resolve();
await D.resolve();
expect(Scheduler).toFlushAndYield(['A', 'D']);
// First render commits A and D.
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>A</span>
<span>D</span>
</Fragment>,
);
// For the second render, we're going to insert items in the middle and end.
ReactNoop.render(
<Foo
items={[['A', A], ['B', B], ['C', C], ['D', D], ['E', E], ['F', F]]}
/>,
);
expect(Scheduler).toFlushAndYield([
'A',
'Suspend! [B]',
'Loading B',
'Suspend! [C]',
'Loading C',
'D',
'Loading E',
]);
// B and C don't get collapsed, but F gets collapsed with E.
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>A</span>
<span>Loading B</span>
<span>Loading C</span>
<span>D</span>
<span>Loading E</span>
</Fragment>,
);
await B.resolve();
expect(Scheduler).toFlushAndYield(['B', 'Suspend! [C]']);
// Incremental loading is suspended.
jest.advanceTimersByTime(500);
// Even though B is unsuspended, it's still in loading state because
// it is blocked by C.
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>A</span>
<span>Loading B</span>
<span>Loading C</span>
<span>D</span>
<span>Loading E</span>
</Fragment>,
);
await C.resolve();
await E.resolve();
expect(Scheduler).toFlushAndYield([
'B',
'C',
'E',
'Suspend! [F]',
'Loading F',
]);
jest.advanceTimersByTime(500);
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>A</span>
<span>B</span>
<span>C</span>
<span>D</span>
<span>E</span>
<span>Loading F</span>
</Fragment>,
);
await F.resolve();
expect(Scheduler).toFlushAndYield(['F']);
jest.advanceTimersByTime(500);
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>A</span>
<span>B</span>
<span>C</span>
<span>D</span>
<span>E</span>
<span>F</span>
</Fragment>,
);
});
it('adding to the middle does not collapse insertions (backwards)', async () => {
let A = createAsyncText('A');
let B = createAsyncText('B');
let C = createAsyncText('C');
let D = createAsyncText('D');
let E = createAsyncText('E');
let F = createAsyncText('F');
function Foo({items}) {
return (
<SuspenseList revealOrder="backwards" tail="collapsed">
{items.map(([key, Component]) => (
<Suspense key={key} fallback={<Text text={'Loading ' + key} />}>
<Component />
</Suspense>
))}
</SuspenseList>
);
}
ReactNoop.render(<Foo items={[['C', C], ['F', F]]} />);
await C.resolve();
await F.resolve();
expect(Scheduler).toFlushAndYield(['F', 'C']);
// First render commits C and F.
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>C</span>
<span>F</span>
</Fragment>,
);
// For the second render, we're going to insert items in the middle and end.
ReactNoop.render(
<Foo
items={[['A', A], ['B', B], ['C', C], ['D', D], ['E', E], ['F', F]]}
/>,
);
expect(Scheduler).toFlushAndYield([
'C',
'Suspend! [D]',
'Loading D',
'Suspend! [E]',
'Loading E',
'F',
'Loading B',
]);
// D and E don't get collapsed, but A gets collapsed with B.
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>Loading B</span>
<span>C</span>
<span>Loading D</span>
<span>Loading E</span>
<span>F</span>
</Fragment>,
);
await E.resolve();
expect(Scheduler).toFlushAndYield(['Suspend! [D]', '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.
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>Loading B</span>
<span>C</span>
<span>Loading D</span>
<span>Loading E</span>
<span>F</span>
</Fragment>,
);
await C.resolve();
await E.resolve();
await B.resolve();
await C.resolve();
await D.resolve();
await E.resolve();
expect(Scheduler).toFlushAndYield([
'D',
'E',
'B',
'Suspend! [A]',
'Loading A',
]);
jest.advanceTimersByTime(500);
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>Loading A</span>
<span>B</span>
<span>C</span>
<span>D</span>
<span>E</span>
<span>F</span>
</Fragment>,
);
await A.resolve();
expect(Scheduler).toFlushAndYield(['A']);
jest.advanceTimersByTime(500);
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>A</span>
<span>B</span>
<span>C</span>
<span>D</span>
<span>E</span>
<span>F</span>
</Fragment>,
);
});
it('adding to the middle of committeed tail does not collapse insertions', async () => {
let A = createAsyncText('A');
let B = createAsyncText('B');
let C = createAsyncText('C');
let D = createAsyncText('D');
let E = createAsyncText('E');
let F = createAsyncText('F');
function SyncD() {
return <Text text="D" />;
}
function Foo({items}) {
return (
<SuspenseList revealOrder="forwards" tail="collapsed">
{items.map(([key, Component]) => (
<Suspense key={key} fallback={<Text text={'Loading ' + key} />}>
<Component />
</Suspense>
))}
</SuspenseList>
);
}
ReactNoop.render(<Foo items={[['A', A], ['D', SyncD]]} />);
await A.resolve();
expect(Scheduler).toFlushAndYield(['A', 'D']);
// First render commits A and D.
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>A</span>
<span>D</span>
</Fragment>,
);
// For the second render, we're going to insert items in the middle and end.
// Note that D now suspends even though it didn't in the first pass.
ReactNoop.render(
<Foo
items={[['A', A], ['B', B], ['C', C], ['D', D], ['E', E], ['F', F]]}
/>,
);
expect(Scheduler).toFlushAndYield([
'A',
'Suspend! [B]',
'Loading B',
'Suspend! [C]',
'Loading C',
'Suspend! [D]',
'Loading D',
'Loading E',
]);
// This is suspended due to the update to D causing a loading state.
jest.advanceTimersByTime(500);
// B and C don't get collapsed, but F gets collapsed with E.
// Even though everything in the bottom of the list is suspended, we don't
// collapse them because D was an update. Not an insertion.
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>A</span>
<span>Loading B</span>
<span>Loading C</span>
<span hidden={true}>D</span>
<span>Loading D</span>
<span>Loading E</span>
</Fragment>,
);
await B.resolve();
expect(Scheduler).toFlushAndYield(['B', 'Suspend! [C]']);
// Incremental loading is suspended.
jest.advanceTimersByTime(500);
// B is able to unblock here because it's part of the tail.
// If D was still visible it wouldn't be part of the tail
// and would be blocked on C like in the other test.
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>A</span>
<span>B</span>
<span>Loading C</span>
<span hidden={true}>D</span>
<span>Loading D</span>
<span>Loading E</span>
</Fragment>,
);
await C.resolve();
await D.resolve();
await E.resolve();
expect(Scheduler).toFlushAndYield([
'C',
'D',
'E',
'Suspend! [F]',
'Loading F',
]);
jest.advanceTimersByTime(500);
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>A</span>
<span>B</span>
<span>C</span>
<span>D</span>
<span>E</span>
<span>Loading F</span>
</Fragment>,
);
await F.resolve();
expect(Scheduler).toFlushAndYield(['F']);
jest.advanceTimersByTime(500);
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>A</span>
<span>B</span>
<span>C</span>
<span>D</span>
<span>E</span>
<span>F</span>
</Fragment>,
);
});
});