Add forwards and backwards options to SuspenseList (#15918)

* Add forwards option

* Add backwards option

* Add comment

* Add customized warning messages for case and typos

* Add some more tests for insertions and updates in start/middle/end
This commit is contained in:
Sebastian Markbåge
2019-06-20 11:03:47 -07:00
committed by GitHub
parent 5368f7316c
commit 270dc2e4de
4 changed files with 734 additions and 29 deletions
+149 -8
View File
@@ -127,6 +127,7 @@ import {
addSubtreeSuspenseContext,
setShallowSuspenseContext,
} from './ReactFiberSuspenseContext';
import {isShowingAnyFallbacks} from './ReactFiberSuspenseComponent';
import {
pushProvider,
propagateContextChange,
@@ -1983,8 +1984,36 @@ function propagateSuspenseContextChange(
}
}
type SuspenseListRevealOrder = 'together' | void;
function findLastContentRow(firstChild: null | Fiber): null | Fiber {
// This is going to find the last row among these children that is already
// showing content on the screen, as opposed to being in fallback state or
// new. If a row has multiple Suspense boundaries, any of them being in the
// fallback state, counts as the whole row being in a fallback state.
// Note that the "rows" will be workInProgress, but any nested children
// will still be current since we haven't rendered them yet. The mounted
// order may not be the same as the new order. We use the new order.
let row = firstChild;
let lastContentRow: null | Fiber = null;
while (row !== null) {
let currentRow = row.alternate;
// New rows can't be content rows.
if (currentRow !== null && !isShowingAnyFallbacks(currentRow)) {
lastContentRow = row;
}
row = row.sibling;
}
return lastContentRow;
}
type SuspenseListRevealOrder = 'forwards' | 'backwards' | 'together' | void;
// This can end up rendering this component multiple passes.
// The first pass splits the children fibers into two sets. A head and tail.
// We first render the head. If anything is in fallback state, we do another
// pass through beginWork to rerender all children (including the tail) with
// the force suspend context. If the first render didn't have anything in
// in fallback state. Then we render each row in the tail one-by-one.
// That happens in the completeWork phase without going back to beginWork.
function updateSuspenseListComponent(
current: Fiber | null,
workInProgress: Fiber,
@@ -2033,6 +2062,10 @@ function updateSuspenseListComponent(
);
suspenseListState = {
didSuspend: true,
isBackwards: false,
rendering: null,
last: null,
tail: null,
};
} else {
let didForceFallback =
@@ -2063,7 +2096,79 @@ function updateSuspenseListComponent(
}
switch (revealOrder) {
// TODO: For other reveal orders we'll need to split the nextChildFibers set.
case 'forwards': {
// If need to force fallbacks in this pass we're just going to
// force the whole set to suspend so we don't have to do anything
// further here.
if (!shouldForceFallback) {
let lastContentRow = findLastContentRow(nextChildFibers);
let tail;
if (lastContentRow === null) {
// The whole list is part of the tail.
// TODO: We could fast path by just rendering the tail now.
tail = nextChildFibers;
nextChildFibers = null;
} else {
// Disconnect the tail rows after the content row.
// We're going to render them separately later.
tail = lastContentRow.sibling;
lastContentRow.sibling = null;
}
if (suspenseListState === null) {
suspenseListState = {
didSuspend: false,
isBackwards: false,
rendering: null,
last: lastContentRow,
tail: tail,
};
} else {
suspenseListState.tail = tail;
}
}
break;
}
case 'backwards': {
// If need to force fallbacks in this pass we're just going to
// force the whole set to suspend so we don't have to do anything
// further here.
if (!shouldForceFallback) {
// We're going to find the first row that has existing content.
// At the same time we're going to reverse the list of everything
// we pass in the meantime. That's going to be our tail in reverse
// order.
let tail = null;
let row = nextChildFibers;
nextChildFibers = null;
while (row !== null) {
let currentRow = row.alternate;
// New rows can't be content rows.
if (currentRow !== null && !isShowingAnyFallbacks(currentRow)) {
// This is the beginning of the main content.
nextChildFibers = row;
break;
}
let nextRow = row.sibling;
row.sibling = tail;
tail = row;
row = nextRow;
}
// TODO: If nextChildFibers is null, we can continue on the tail immediately.
if (suspenseListState === null) {
suspenseListState = {
didSuspend: false,
isBackwards: true,
rendering: null,
last: null,
tail: tail,
};
} else {
suspenseListState.isBackwards = true;
suspenseListState.tail = tail;
}
}
break;
}
case 'together': {
break;
}
@@ -2076,12 +2181,48 @@ function updateSuspenseListComponent(
!didWarnAboutRevealOrder[revealOrder]
) {
didWarnAboutRevealOrder[revealOrder] = true;
warning(
false,
'"%s" is not a supported revealOrder on <SuspenseList />. ' +
'Did you mean "together"?',
revealOrder,
);
if (typeof revealOrder === 'string') {
switch (revealOrder.toLowerCase()) {
case 'together':
case 'forwards':
case 'backwards': {
warning(
false,
'"%s" is not a valid value for revealOrder on <SuspenseList />. ' +
'Use lowercase "%s" instead.',
revealOrder,
revealOrder.toLowerCase(),
);
break;
}
case 'forward':
case 'backward': {
warning(
false,
'"%s" is not a valid value for revealOrder on <SuspenseList />. ' +
'React uses the -s suffix in the spelling. Use "%ss" instead.',
revealOrder,
revealOrder.toLowerCase(),
);
break;
}
default:
warning(
false,
'"%s" is not a supported revealOrder on <SuspenseList />. ' +
'Did you mean "together", "forwards" or "backwards"?',
revealOrder,
);
break;
}
} else {
warning(
false,
'%s is not a supported value for revealOrder on <SuspenseList />. ' +
'Did you mean "together", "forwards" or "backwards"?',
revealOrder,
);
}
}
}
// We mark this as having captured but it really just says to the
+83 -20
View File
@@ -18,7 +18,10 @@ import type {
ChildSet,
} from './ReactFiberHostConfig';
import type {ReactEventComponentInstance} from 'shared/ReactTypes';
import type {SuspenseState} from './ReactFiberSuspenseComponent';
import type {
SuspenseState,
SuspenseListState,
} from './ReactFiberSuspenseComponent';
import type {SuspenseContext} from './ReactFiberSuspenseContext';
import {
@@ -84,7 +87,12 @@ import {
InvisibleParentSuspenseContext,
hasSuspenseContext,
popSuspenseContext,
pushSuspenseContext,
setShallowSuspenseContext,
ForceSuspenseFallback,
setDefaultShallowSuspenseContext,
} from './ReactFiberSuspenseContext';
import {isShowingAnyFallbacks} from './ReactFiberSuspenseComponent';
import {
isContextProvider as isLegacyContextProvider,
popContext as popLegacyContext,
@@ -917,25 +925,80 @@ function completeWork(
popSuspenseContext(workInProgress);
if ((workInProgress.effectTag & DidCapture) === NoEffect) {
// 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.
const needsRerender = hasSuspendedChildrenAndNewContent(
workInProgress,
renderedChildren,
);
if (needsRerender) {
// Rerender the whole list, but this time, we'll force fallbacks
// to stay in place.
workInProgress.effectTag |= DidCapture;
// Reset the effect list before doing the second pass since that's now invalid.
workInProgress.firstEffect = workInProgress.lastEffect = null;
// Schedule work so we know not to bail out.
workInProgress.expirationTime = renderExpirationTime;
return workInProgress;
let suspenseListState: null | SuspenseListState =
workInProgress.memoizedState;
if (
suspenseListState === null ||
suspenseListState.rendering === null
) {
// 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.
const needsRerender = hasSuspendedChildrenAndNewContent(
workInProgress,
renderedChildren,
);
if (needsRerender) {
// Rerender the whole list, but this time, we'll force fallbacks
// to stay in place.
workInProgress.effectTag |= DidCapture;
// Reset the effect list before doing the second pass since that's now invalid.
workInProgress.firstEffect = workInProgress.lastEffect = null;
// Schedule work so we know not to bail out.
workInProgress.expirationTime = renderExpirationTime;
return workInProgress;
}
} else {
// Append the rendered row to the child list.
let rendered = suspenseListState.rendering;
if (!suspenseListState.didSuspend) {
suspenseListState.didSuspend = isShowingAnyFallbacks(rendered);
}
if (suspenseListState.isBackwards) {
// The effect list of the backwards tail will have been added
// to the end. This breaks the guarantee that life-cycles fire in
// sibling order but that isn't a strong guarantee promised by React.
// Especially since these might also just pop in during future commits.
// Append to the beginning of the list.
rendered.sibling = workInProgress.child;
workInProgress.child = rendered;
} else {
let previousSibling = suspenseListState.last;
if (previousSibling !== null) {
previousSibling.sibling = rendered;
} else {
workInProgress.child = rendered;
}
suspenseListState.last = rendered;
}
}
if (suspenseListState !== null && suspenseListState.tail !== null) {
// We still have tail rows to render.
// Pop a row.
let next = suspenseListState.tail;
suspenseListState.rendering = next;
suspenseListState.tail = next.sibling;
next.sibling = null;
// Restore the context.
// TODO: We can probably just avoid popping it instead and only
// setting it the first time we go from not suspended to suspended.
let suspenseContext = suspenseStackCursor.current;
if (suspenseListState.didSuspend) {
suspenseContext = setShallowSuspenseContext(
suspenseContext,
ForceSuspenseFallback,
);
} else {
suspenseContext = setDefaultShallowSuspenseContext(suspenseContext);
}
pushSuspenseContext(workInProgress, suspenseContext);
// Do a pass over the next row.
return next;
}
} else {
workInProgress.effectTag &= ~DidCapture;
@@ -8,12 +8,20 @@
*/
import type {Fiber} from './ReactFiber';
import {SuspenseComponent} from 'shared/ReactWorkTags';
// TODO: This is now an empty object. Should we switch this to a boolean?
export type SuspenseState = {||};
export type SuspenseListState = {|
didSuspend: boolean,
isBackwards: boolean,
// The currently rendering tail row.
rendering: null | Fiber,
// The last of the already rendered children.
last: null | Fiber,
// Remaining rows on the tail of the list.
tail: null | Fiber,
|};
export function shouldCaptureSuspense(
@@ -43,3 +51,31 @@ export function shouldCaptureSuspense(
// If the parent is not able to handle it, we must handle it.
return true;
}
export function isShowingAnyFallbacks(row: Fiber): boolean {
let node = row;
while (node !== null) {
if (node.tag === SuspenseComponent) {
const state: SuspenseState | null = node.memoizedState;
if (state !== null) {
return true;
}
} else if (node.child !== null) {
node.child.return = node;
node = node.child;
continue;
}
if (node === row) {
return false;
}
while (node.sibling === null) {
if (node.return === null || node.return === row) {
return false;
}
node = node.return;
}
node.sibling.return = node.return;
node = node.sibling;
}
return false;
}
@@ -56,7 +56,46 @@ describe('ReactSuspenseList', () => {
expect(() => Scheduler.flushAll()).toWarnDev([
'Warning: "something" is not a supported revealOrder on ' +
'<SuspenseList />. Did you mean "together"?' +
'<SuspenseList />. Did you mean "together", "forwards" or "backwards"?' +
'\n in SuspenseList (at **)' +
'\n in Foo (at **)',
]);
});
it('warns if a upper case revealOrder option is used', () => {
function Foo() {
return (
<SuspenseList revealOrder="TOGETHER">
<Suspense fallback="Loading">Content</Suspense>
</SuspenseList>
);
}
ReactNoop.render(<Foo />);
expect(() => Scheduler.flushAll()).toWarnDev([
'Warning: "TOGETHER" is not a valid value for revealOrder on ' +
'<SuspenseList />. Use lowercase "together" instead.' +
'\n in SuspenseList (at **)' +
'\n in Foo (at **)',
]);
});
it('warns if a misspelled revealOrder option is used', () => {
function Foo() {
return (
<SuspenseList revealOrder="forward">
<Suspense fallback="Loading">Content</Suspense>
</SuspenseList>
);
}
ReactNoop.render(<Foo />);
expect(() => Scheduler.flushAll()).toWarnDev([
'Warning: "forward" is not a valid value for revealOrder on ' +
'<SuspenseList />. React uses the -s suffix in the spelling. ' +
'Use "forwards" instead.' +
'\n in SuspenseList (at **)' +
'\n in Foo (at **)',
]);
@@ -562,4 +601,430 @@ describe('ReactSuspenseList', () => {
</Fragment>,
);
});
it('displays each items in "forwards" order', async () => {
let A = createAsyncText('A');
let B = createAsyncText('B');
let C = createAsyncText('C');
function Foo() {
return (
<SuspenseList revealOrder="forwards">
<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>
);
}
await C.resolve();
ReactNoop.render(<Foo />);
expect(Scheduler).toFlushAndYield([
'Suspend! [A]',
'Loading A',
'Loading B',
'Loading C',
]);
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>Loading A</span>
<span>Loading B</span>
<span>Loading C</span>
</Fragment>,
);
await A.resolve();
expect(Scheduler).toFlushAndYield(['A', 'Suspend! [B]']);
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>A</span>
<span>Loading B</span>
<span>Loading C</span>
</Fragment>,
);
await B.resolve();
expect(Scheduler).toFlushAndYield(['B', 'C']);
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>A</span>
<span>B</span>
<span>C</span>
</Fragment>,
);
});
it('displays each items in "backwards" order', async () => {
let A = createAsyncText('A');
let B = createAsyncText('B');
let C = createAsyncText('C');
function Foo() {
return (
<SuspenseList revealOrder="backwards">
<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>
);
}
await A.resolve();
ReactNoop.render(<Foo />);
expect(Scheduler).toFlushAndYield([
'Suspend! [C]',
'Loading C',
'Loading B',
'Loading A',
]);
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>Loading A</span>
<span>Loading B</span>
<span>Loading C</span>
</Fragment>,
);
await C.resolve();
expect(Scheduler).toFlushAndYield(['C', 'Suspend! [B]']);
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>Loading A</span>
<span>Loading B</span>
<span>C</span>
</Fragment>,
);
await B.resolve();
expect(Scheduler).toFlushAndYield(['B', 'A']);
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>A</span>
<span>B</span>
<span>C</span>
</Fragment>,
);
});
it('displays added row at the top "together" and the bottom in "forwards" order', 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">
{items.map(([key, Component]) => (
<Suspense key={key} fallback={<Text text={'Loading ' + key} />}>
<Component />
</Suspense>
))}
</SuspenseList>
);
}
await B.resolve();
await D.resolve();
ReactNoop.render(<Foo items={[['B', B], ['D', D]]} />);
expect(Scheduler).toFlushAndYield(['B', 'D']);
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>B</span>
<span>D</span>
</Fragment>,
);
// Insert items in the beginning, middle and end.
ReactNoop.render(
<Foo
items={[['A', A], ['B', B], ['C', C], ['D', D], ['E', E], ['F', F]]}
/>,
);
expect(Scheduler).toFlushAndYield([
'Suspend! [A]',
'Loading A',
'B',
'Suspend! [C]',
'Loading C',
'D',
'Suspend! [E]',
'Loading E',
'Loading F',
]);
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>Loading A</span>
<span>B</span>
<span>Loading C</span>
<span>D</span>
<span>Loading E</span>
<span>Loading F</span>
</Fragment>,
);
await A.resolve();
expect(Scheduler).toFlushAndYield(['A', 'Suspend! [C]']);
// Even though we could show A, it is still in a fallback state because
// C is not yet resolved. We need to resolve everything in the head first.
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>Loading A</span>
<span>B</span>
<span>Loading C</span>
<span>D</span>
<span>Loading E</span>
<span>Loading F</span>
</Fragment>,
);
await C.resolve();
expect(Scheduler).toFlushAndYield(['A', 'C', 'Suspend! [E]']);
// We can now resolve the full head.
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>A</span>
<span>B</span>
<span>C</span>
<span>D</span>
<span>Loading E</span>
<span>Loading F</span>
</Fragment>,
);
await E.resolve();
expect(Scheduler).toFlushAndYield(['E', 'Suspend! [F]']);
// In the tail we can resolve one-by-one.
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();
// We can also delete some items.
ReactNoop.render(<Foo items={[['D', D], ['E', E], ['F', F]]} />);
expect(Scheduler).toFlushAndYield(['D', 'E', 'F']);
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>D</span>
<span>E</span>
<span>F</span>
</Fragment>,
);
});
it('displays added row at the top "together" and the bottom in "forwards" order', async () => {
let A = createAsyncText('A');
let B = createAsyncText('B');
let D = createAsyncText('D');
let F = createAsyncText('F');
function createSyncText(text) {
return function() {
return <Text text={text} />;
};
}
let As = createSyncText('A');
let Bs = createSyncText('B');
let Cs = createSyncText('C');
let Ds = createSyncText('D');
let Es = createSyncText('E');
let Fs = createSyncText('F');
function Foo({items}) {
return (
<SuspenseList revealOrder="backwards">
{items.map(([key, Component]) => (
<Suspense key={key} fallback={<Text text={'Loading ' + key} />}>
<Component />
</Suspense>
))}
</SuspenseList>
);
}
// The first pass doesn't suspend.
ReactNoop.render(
<Foo
items={[
['A', As],
['B', Bs],
['C', Cs],
['D', Ds],
['E', Es],
['F', Fs],
]}
/>,
);
expect(Scheduler).toFlushAndYield(['F', 'E', 'D', 'C', 'B', 'A']);
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>A</span>
<span>B</span>
<span>C</span>
<span>D</span>
<span>E</span>
<span>F</span>
</Fragment>,
);
// Update items in the beginning, middle and end to start suspending.
ReactNoop.render(
<Foo
items={[['A', A], ['B', B], ['C', Cs], ['D', D], ['E', Es], ['F', F]]}
/>,
);
expect(Scheduler).toFlushAndYield([
'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
// time to display their loading states.
jest.advanceTimersByTime(500);
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span hidden={true}>A</span>
<span>Loading A</span>
<span hidden={true}>B</span>
<span>Loading B</span>
<span>C</span>
<span hidden={true}>D</span>
<span>Loading D</span>
<span>E</span>
<span hidden={true}>F</span>
<span>Loading F</span>
</Fragment>,
);
await F.resolve();
expect(Scheduler).toFlushAndYield(['F']);
// Even though we could show F, it is still in a fallback state because
// E is not yet resolved. We need to resolve everything in the head first.
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span hidden={true}>A</span>
<span>Loading A</span>
<span hidden={true}>B</span>
<span>Loading B</span>
<span>C</span>
<span hidden={true}>D</span>
<span>Loading D</span>
<span>E</span>
<span hidden={true}>F</span>
<span>Loading F</span>
</Fragment>,
);
await D.resolve();
expect(Scheduler).toFlushAndYield(['D', 'F', 'Suspend! [B]']);
// We can now resolve the full head.
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span hidden={true}>A</span>
<span>Loading A</span>
<span hidden={true}>B</span>
<span>Loading B</span>
<span>C</span>
<span>D</span>
<span>E</span>
<span>F</span>
</Fragment>,
);
await B.resolve();
expect(Scheduler).toFlushAndYield(['B', 'Suspend! [A]']);
// In the tail we can resolve one-by-one.
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span hidden={true}>A</span>
<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']);
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>A</span>
<span>B</span>
<span>C</span>
<span>D</span>
<span>E</span>
<span>F</span>
</Fragment>,
);
});
});