Add SuspenseList Component (#15902)

* Add SuspenseList component type

* Push SuspenseContext for SuspenseList

* Force Suspense boundaries into their fallback state

In the "together" mode, we do a second render pass that forces the
fallbacks to stay in place, if not all can unsuspend at once.

* Add test

* Transfer thennables to the SuspenseList

This way, we end up retrying the SuspenseList in case the nested boundary
that just suspended doesn't actually get mounted with this set of
thennables. This happens when the second pass renders the fallback
directly without first attempting to render the content.

* Add warning for unsupported displayOrder

* Add tests for nested sibling boundaries and nested lists

* Fix nested SuspenseList forwarding thennables

* Rename displayOrder to revealOrder

Display order has some "display list" connotations making it sound like
a z-index thing.

Reveal indicates that this isn't really about when something gets rendered
or is ready to be rendered. It's about when content that is already there
gets to be revealed.

* Add test for avoided boundaries

* Make SuspenseList a noop in legacy mode

* Use an explicit suspense list state object

This will be used for more things in the directional case.
This commit is contained in:
Sebastian Markbåge 2019-06-19 19:34:28 -07:00 committed by GitHub
parent e9d0a3ff25
commit 76864f7ff7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 977 additions and 15 deletions

View File

@ -34,7 +34,7 @@ function initModules() {
};
}
const {resetModules, serverRender} = ReactDOMServerIntegrationUtils(
const {resetModules, serverRender, itRenders} = ReactDOMServerIntegrationUtils(
initModules,
);
@ -98,4 +98,24 @@ describe('ReactDOMServerSuspense', () => {
'<div>Children</div><!--$!--><div>Fallback</div><!--/$-->',
);
});
itRenders('a SuspenseList component and its children', async render => {
const element = await render(
<React.unstable_SuspenseList>
<React.Suspense fallback="Loading A">
<div>A</div>
</React.Suspense>
<React.Suspense fallback="Loading B">
<div>B</div>
</React.Suspense>
</React.unstable_SuspenseList>,
);
const parent = element.parentNode;
const divA = parent.children[0];
expect(divA.tagName).toBe('DIV');
expect(divA.textContent).toBe('A');
const divB = parent.children[1];
expect(divB.tagName).toBe('DIV');
expect(divB.textContent).toBe('B');
});
});

View File

@ -31,6 +31,7 @@ import {
REACT_STRICT_MODE_TYPE,
REACT_CONCURRENT_MODE_TYPE,
REACT_SUSPENSE_TYPE,
REACT_SUSPENSE_LIST_TYPE,
REACT_PORTAL_TYPE,
REACT_PROFILER_TYPE,
REACT_PROVIDER_TYPE,
@ -970,6 +971,7 @@ class ReactDOMServerRenderer {
case REACT_STRICT_MODE_TYPE:
case REACT_CONCURRENT_MODE_TYPE:
case REACT_PROFILER_TYPE:
case REACT_SUSPENSE_LIST_TYPE:
case REACT_FRAGMENT_TYPE: {
const nextChildren = toArray(
((nextChild: any): ReactElement).props.children,

View File

@ -43,6 +43,7 @@ import {
ContextConsumer,
Profiler,
SuspenseComponent,
SuspenseListComponent,
FunctionComponent,
MemoComponent,
SimpleMemoComponent,
@ -75,6 +76,7 @@ import {
REACT_CONTEXT_TYPE,
REACT_CONCURRENT_MODE_TYPE,
REACT_SUSPENSE_TYPE,
REACT_SUSPENSE_LIST_TYPE,
REACT_MEMO_TYPE,
REACT_LAZY_TYPE,
REACT_EVENT_COMPONENT_TYPE,
@ -531,6 +533,13 @@ export function createFiberFromTypeAndProps(
return createFiberFromProfiler(pendingProps, mode, expirationTime, key);
case REACT_SUSPENSE_TYPE:
return createFiberFromSuspense(pendingProps, mode, expirationTime, key);
case REACT_SUSPENSE_LIST_TYPE:
return createFiberFromSuspenseList(
pendingProps,
mode,
expirationTime,
key,
);
default: {
if (typeof type === 'object' && type !== null) {
switch (type.$$typeof) {
@ -722,14 +731,33 @@ export function createFiberFromSuspense(
const fiber = createFiber(SuspenseComponent, pendingProps, key, mode);
// TODO: The SuspenseComponent fiber shouldn't have a type. It has a tag.
const type = REACT_SUSPENSE_TYPE;
fiber.elementType = type;
fiber.type = type;
// This needs to be fixed in getComponentName so that it relies on the tag
// instead.
fiber.type = REACT_SUSPENSE_TYPE;
fiber.elementType = REACT_SUSPENSE_TYPE;
fiber.expirationTime = expirationTime;
return fiber;
}
export function createFiberFromSuspenseList(
pendingProps: any,
mode: TypeOfMode,
expirationTime: ExpirationTime,
key: null | string,
) {
const fiber = createFiber(SuspenseListComponent, pendingProps, key, mode);
if (__DEV__) {
// TODO: The SuspenseListComponent fiber shouldn't have a type. It has a tag.
// This needs to be fixed in getComponentName so that it relies on the tag
// instead.
fiber.type = REACT_SUSPENSE_LIST_TYPE;
}
fiber.elementType = REACT_SUSPENSE_LIST_TYPE;
fiber.expirationTime = expirationTime;
return fiber;
}
export function createFiberFromText(
content: string,
mode: TypeOfMode,

View File

@ -11,7 +11,10 @@ import type {ReactProviderType, ReactContext} from 'shared/ReactTypes';
import type {Fiber} from './ReactFiber';
import type {FiberRoot} from './ReactFiberRoot';
import type {ExpirationTime} from './ReactFiberExpirationTime';
import type {SuspenseState} from './ReactFiberSuspenseComponent';
import type {
SuspenseState,
SuspenseListState,
} from './ReactFiberSuspenseComponent';
import type {SuspenseContext} from './ReactFiberSuspenseContext';
import checkPropTypes from 'prop-types/checkPropTypes';
@ -31,6 +34,7 @@ import {
ContextConsumer,
Profiler,
SuspenseComponent,
SuspenseListComponent,
DehydratedSuspenseComponent,
MemoComponent,
SimpleMemoComponent,
@ -121,6 +125,7 @@ import {
hasSuspenseContext,
setDefaultShallowSuspenseContext,
addSubtreeSuspenseContext,
setShallowSuspenseContext,
} from './ReactFiberSuspenseContext';
import {
pushProvider,
@ -128,6 +133,7 @@ import {
readContext,
prepareToReadContext,
calculateChangedBits,
scheduleWorkOnParentPath,
} from './ReactFiberNewContext';
import {resetHooks, renderWithHooks, bailoutHooks} from './ReactFiberHooks';
import {stopProfilerTimerIfRunning} from './ReactProfilerTimer';
@ -182,6 +188,7 @@ let didWarnAboutGetDerivedStateOnFunctionComponent;
let didWarnAboutFunctionRefs;
export let didWarnAboutReassigningProps;
let didWarnAboutMaxDuration;
let didWarnAboutRevealOrder;
if (__DEV__) {
didWarnAboutBadClass = {};
@ -191,6 +198,7 @@ if (__DEV__) {
didWarnAboutFunctionRefs = {};
didWarnAboutReassigningProps = false;
didWarnAboutMaxDuration = false;
didWarnAboutRevealOrder = {};
}
export function reconcileChildren(
@ -1433,6 +1441,22 @@ function validateFunctionComponentInDev(workInProgress: Fiber, Component: any) {
// TODO: This is now an empty object. Should we just make it a boolean?
const SUSPENDED_MARKER: SuspenseState = ({}: any);
function shouldRemainOnFallback(
suspenseContext: SuspenseContext,
current: null | Fiber,
workInProgress: Fiber,
) {
// If the context is telling us that we should show a fallback, and we're not
// already showing content, then we should show the fallback instead.
return (
hasSuspenseContext(
suspenseContext,
(ForceSuspenseFallback: SuspenseContext),
) &&
(current === null || current.memoizedState !== null)
);
}
function updateSuspenseComponent(
current,
workInProgress,
@ -1455,10 +1479,7 @@ function updateSuspenseComponent(
if (
(workInProgress.effectTag & DidCapture) !== NoEffect ||
hasSuspenseContext(
suspenseContext,
(ForceSuspenseFallback: SuspenseContext),
)
shouldRemainOnFallback(suspenseContext, current, workInProgress)
) {
// Something in this boundary's subtree already suspended. Switch to
// rendering the fallback children.
@ -1918,6 +1939,162 @@ function updateDehydratedSuspenseComponent(
}
}
function propagateSuspenseContextChange(
workInProgress: Fiber,
firstChild: null | Fiber,
renderExpirationTime: ExpirationTime,
): void {
// Mark any Suspense boundaries with fallbacks as having work to do.
// If they were previously forced into fallbacks, they may now be able
// to unblock.
let node = firstChild;
while (node !== null) {
if (node.tag === SuspenseComponent) {
const state: SuspenseState | null = node.memoizedState;
if (state !== null) {
if (node.expirationTime < renderExpirationTime) {
node.expirationTime = renderExpirationTime;
}
let alternate = node.alternate;
if (
alternate !== null &&
alternate.expirationTime < renderExpirationTime
) {
alternate.expirationTime = renderExpirationTime;
}
scheduleWorkOnParentPath(node.return, renderExpirationTime);
}
} else if (node.child !== null) {
node.child.return = node;
node = node.child;
continue;
}
if (node === workInProgress) {
return;
}
while (node.sibling === null) {
if (node.return === null || node.return === workInProgress) {
return;
}
node = node.return;
}
node.sibling.return = node.return;
node = node.sibling;
}
}
type SuspenseListRevealOrder = 'together' | void;
function updateSuspenseListComponent(
current: Fiber | null,
workInProgress: Fiber,
renderExpirationTime: ExpirationTime,
) {
const nextProps = workInProgress.pendingProps;
const revealOrder: SuspenseListRevealOrder = nextProps.revealOrder;
const nextChildren = nextProps.children;
let nextChildFibers;
if (current === null) {
nextChildFibers = mountChildFibers(
workInProgress,
null,
nextChildren,
renderExpirationTime,
);
} else {
nextChildFibers = reconcileChildFibers(
workInProgress,
current.child,
nextChildren,
renderExpirationTime,
);
}
let suspenseContext: SuspenseContext = suspenseStackCursor.current;
let shouldForceFallback = hasSuspenseContext(
suspenseContext,
(ForceSuspenseFallback: SuspenseContext),
);
if ((workInProgress.effectTag & DidCapture) !== NoEffect) {
// This is the second pass. In this pass, we should force the
// fallbacks in place.
shouldForceFallback = true;
}
let suspenseListState: null | SuspenseListState = null;
if (shouldForceFallback) {
suspenseContext = setShallowSuspenseContext(
suspenseContext,
ForceSuspenseFallback,
);
suspenseListState = {
didSuspend: true,
};
} else {
let didForceFallback =
current !== null &&
current.memoizedState !== null &&
(current.memoizedState: SuspenseListState).didSuspend;
if (didForceFallback) {
// If we previously forced a fallback, we need to schedule work
// on any nested boundaries to let them know to try to render
// again. This is the same as context updating.
propagateSuspenseContextChange(
workInProgress,
nextChildFibers,
renderExpirationTime,
);
}
suspenseContext = setDefaultShallowSuspenseContext(suspenseContext);
}
pushSuspenseContext(workInProgress, suspenseContext);
if ((workInProgress.mode & BatchedMode) === NoMode) {
// Outside of batched mode, SuspenseList doesn't work so we just
// use make it a noop by treating it as the default revealOrder.
workInProgress.effectTag |= DidCapture;
workInProgress.child = nextChildFibers;
return nextChildFibers;
}
switch (revealOrder) {
// TODO: For other reveal orders we'll need to split the nextChildFibers set.
case 'together': {
break;
}
default: {
// The default reveal order is the same as not having
// a boundary.
if (__DEV__) {
if (
revealOrder !== undefined &&
!didWarnAboutRevealOrder[revealOrder]
) {
didWarnAboutRevealOrder[revealOrder] = true;
warning(
false,
'"%s" is not a supported revealOrder on <SuspenseList />. ' +
'Did you mean "together"?',
revealOrder,
);
}
}
// We mark this as having captured but it really just says to the
// complete phase that we should treat this as done, whatever form
// it is in. No need for a second pass.
workInProgress.effectTag |= DidCapture;
}
}
workInProgress.memoizedState = suspenseListState;
workInProgress.child = nextChildFibers;
return nextChildFibers;
}
function updatePortalComponent(
current: Fiber | null,
workInProgress: Fiber,
@ -2367,6 +2544,21 @@ function beginWork(
}
break;
}
case SuspenseListComponent: {
// Check if the children have any pending work.
const childExpirationTime = workInProgress.childExpirationTime;
if (childExpirationTime < renderExpirationTime) {
pushSuspenseContext(workInProgress, suspenseStackCursor.current);
// None of the children have any work, so we can do a fast bailout.
return null;
}
// Try the normal path.
return updateSuspenseListComponent(
current,
workInProgress,
renderExpirationTime,
);
}
case EventComponent:
if (enableEventAPI) {
pushHostContextForEventComponent(workInProgress);
@ -2556,6 +2748,13 @@ function beginWork(
}
break;
}
case SuspenseListComponent: {
return updateSuspenseListComponent(
current,
workInProgress,
renderExpirationTime,
);
}
case EventComponent: {
if (enableEventAPI) {
return updateEventComponent(

View File

@ -46,6 +46,7 @@ import {
SimpleMemoComponent,
EventComponent,
EventTarget,
SuspenseListComponent,
} from 'shared/ReactWorkTags';
import {
invokeGuardedCallback,
@ -590,6 +591,7 @@ function commitLifeCycles(
return;
}
case SuspenseComponent:
case SuspenseListComponent:
case IncompleteClassComponent:
return;
case EventTarget: {
@ -1182,6 +1184,11 @@ function commitWork(current: Fiber | null, finishedWork: Fiber): void {
}
case SuspenseComponent: {
commitSuspenseComponent(finishedWork);
attachSuspenseRetryListeners(finishedWork);
return;
}
case SuspenseListComponent: {
attachSuspenseRetryListeners(finishedWork);
return;
}
}
@ -1256,6 +1263,11 @@ function commitWork(current: Fiber | null, finishedWork: Fiber): void {
}
case SuspenseComponent: {
commitSuspenseComponent(finishedWork);
attachSuspenseRetryListeners(finishedWork);
return;
}
case SuspenseListComponent: {
attachSuspenseRetryListeners(finishedWork);
return;
}
case IncompleteClassComponent: {
@ -1290,7 +1302,9 @@ function commitSuspenseComponent(finishedWork: Fiber) {
if (supportsMutation && primaryChildParent !== null) {
hideOrUnhideAllChildren(primaryChildParent, newDidTimeout);
}
}
function attachSuspenseRetryListeners(finishedWork: Fiber) {
// If this boundary just timed out, then it will have a set of thenables.
// For each thenable, attach a listener so that when it resolves, React
// attempts to re-render the boundary in the primary (pre-timeout) state.

View File

@ -36,6 +36,7 @@ import {
Mode,
Profiler,
SuspenseComponent,
SuspenseListComponent,
DehydratedSuspenseComponent,
MemoComponent,
SimpleMemoComponent,
@ -522,6 +523,84 @@ if (supportsMutation) {
};
}
// 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) {
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,
@ -834,6 +913,35 @@ function completeWork(
}
break;
}
case SuspenseListComponent: {
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;
}
} else {
workInProgress.effectTag &= ~DidCapture;
}
break;
}
case EventComponent: {
if (enableEventAPI) {
popHostContext(workInProgress);

View File

@ -155,7 +155,7 @@ export function calculateChangedBits<T>(
}
}
function scheduleWorkOnParentPath(
export function scheduleWorkOnParentPath(
parent: Fiber | null,
renderExpirationTime: ExpirationTime,
) {

View File

@ -12,6 +12,10 @@ import type {Fiber} from './ReactFiber';
// TODO: This is now an empty object. Should we switch this to a boolean?
export type SuspenseState = {||};
export type SuspenseListState = {|
didSuspend: boolean,
|};
export function shouldCaptureSuspense(
workInProgress: Fiber,
hasInvisibleParent: boolean,

View File

@ -17,6 +17,7 @@ import {
HostPortal,
ContextProvider,
SuspenseComponent,
SuspenseListComponent,
DehydratedSuspenseComponent,
EventComponent,
EventTarget,
@ -95,6 +96,12 @@ function unwindWork(
}
return null;
}
case SuspenseListComponent: {
popSuspenseContext(workInProgress);
// SuspenseList doesn't actually catch anything. It should've been
// caught by a nested boundary. If not, it should bubble through.
return null;
}
case HostPortal:
popHostContainer(workInProgress);
return null;
@ -142,6 +149,9 @@ function unwindInterruptedWork(interruptedWork: Fiber) {
popSuspenseContext(interruptedWork);
}
break;
case SuspenseListComponent:
popSuspenseContext(interruptedWork);
break;
case ContextProvider:
popProvider(interruptedWork);
break;

View File

@ -2092,10 +2092,10 @@ export function pingSuspendedRoot(
}
export function retryTimedOutBoundary(boundaryFiber: Fiber) {
// The boundary fiber (a Suspense component) previously timed out and was
// rendered in its fallback state. One of the promises that suspended it has
// resolved, which means at least part of the tree was likely unblocked. Try
// rendering again, at a new expiration time.
// The boundary fiber (a Suspense component or SuspenseList component)
// previously was rendered in its fallback state. One of the promises that
// suspended it has resolved, which means at least part of the tree was
// likely unblocked. Try rendering again, at a new expiration time.
const currentTime = requestCurrentTime();
const suspenseConfig = null; // Retries don't carry over the already committed update.
const retryTime = computeExpirationForFiber(

View File

@ -0,0 +1,565 @@
let React;
let ReactFeatureFlags;
let Fragment;
let ReactNoop;
let Scheduler;
let Suspense;
let SuspenseList;
describe('ReactSuspenseList', () => {
beforeEach(() => {
jest.resetModules();
ReactFeatureFlags = require('shared/ReactFeatureFlags');
ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false;
ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = false;
React = require('react');
Fragment = React.Fragment;
ReactNoop = require('react-noop-renderer');
Scheduler = require('scheduler');
Suspense = React.Suspense;
SuspenseList = React.unstable_SuspenseList;
});
function Text(props) {
Scheduler.yieldValue(props.text);
return <span>{props.text}</span>;
}
function createAsyncText(text) {
let resolved = false;
let Component = function() {
if (!resolved) {
Scheduler.yieldValue('Suspend! [' + text + ']');
throw promise;
}
return <Text text={text} />;
};
let promise = new Promise(resolve => {
Component.resolve = function() {
resolved = true;
return resolve();
};
});
return Component;
}
it('warns if an unsupported revealOrder option is used', () => {
function Foo() {
return (
<SuspenseList revealOrder="something">
<Suspense fallback="Loading">Content</Suspense>
</SuspenseList>
);
}
ReactNoop.render(<Foo />);
expect(() => Scheduler.flushAll()).toWarnDev([
'Warning: "something" is not a supported revealOrder on ' +
'<SuspenseList />. Did you mean "together"?' +
'\n in SuspenseList (at **)' +
'\n in Foo (at **)',
]);
});
it('shows content independently by default', async () => {
let A = createAsyncText('A');
let B = createAsyncText('B');
let C = createAsyncText('C');
function Foo() {
return (
<SuspenseList>
<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([
'A',
'Suspend! [B]',
'Loading B',
'Suspend! [C]',
'Loading C',
]);
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>A</span>
<span>Loading B</span>
<span>Loading C</span>
</Fragment>,
);
await C.resolve();
expect(Scheduler).toFlushAndYield(['C']);
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>A</span>
<span>Loading B</span>
<span>C</span>
</Fragment>,
);
await B.resolve();
expect(Scheduler).toFlushAndYield(['B']);
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>A</span>
<span>B</span>
<span>C</span>
</Fragment>,
);
});
it('shows content independently in legacy mode regardless of option', async () => {
let A = createAsyncText('A');
let B = createAsyncText('B');
let C = createAsyncText('C');
function Foo() {
return (
<SuspenseList revealOrder="together">
<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.renderLegacySyncRoot(<Foo />);
expect(Scheduler).toHaveYielded([
'A',
'Suspend! [B]',
'Loading B',
'Suspend! [C]',
'Loading C',
]);
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>A</span>
<span>Loading B</span>
<span>Loading C</span>
</Fragment>,
);
await C.resolve();
expect(Scheduler).toFlushAndYield(['C']);
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>A</span>
<span>Loading B</span>
<span>C</span>
</Fragment>,
);
await B.resolve();
expect(Scheduler).toFlushAndYield(['B']);
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>A</span>
<span>B</span>
<span>C</span>
</Fragment>,
);
});
it('displays all "together"', async () => {
let A = createAsyncText('A');
let B = createAsyncText('B');
let C = createAsyncText('C');
function Foo() {
return (
<SuspenseList revealOrder="together">
<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([
'A',
'Suspend! [B]',
'Loading B',
'Suspend! [C]',
'Loading C',
'Loading A',
'Loading B',
'Loading C',
]);
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>Loading A</span>
<span>Loading B</span>
<span>Loading C</span>
</Fragment>,
);
await B.resolve();
expect(Scheduler).toFlushAndYield(['A', 'B', 'Suspend! [C]']);
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>Loading A</span>
<span>Loading B</span>
<span>Loading C</span>
</Fragment>,
);
await C.resolve();
expect(Scheduler).toFlushAndYield(['A', 'B', 'C']);
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>A</span>
<span>B</span>
<span>C</span>
</Fragment>,
);
});
it('displays all "together" even when nested as siblings', async () => {
let A = createAsyncText('A');
let B = createAsyncText('B');
let C = createAsyncText('C');
function Foo() {
return (
<SuspenseList revealOrder="together">
<div>
<Suspense fallback={<Text text="Loading A" />}>
<A />
</Suspense>
<Suspense fallback={<Text text="Loading B" />}>
<B />
</Suspense>
</div>
<div>
<Suspense fallback={<Text text="Loading C" />}>
<C />
</Suspense>
</div>
</SuspenseList>
);
}
await A.resolve();
ReactNoop.render(<Foo />);
expect(Scheduler).toFlushAndYield([
'A',
'Suspend! [B]',
'Loading B',
'Suspend! [C]',
'Loading C',
'Loading A',
'Loading B',
'Loading C',
]);
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<div>
<span>Loading A</span>
<span>Loading B</span>
</div>
<div>
<span>Loading C</span>
</div>
</Fragment>,
);
await B.resolve();
expect(Scheduler).toFlushAndYield(['A', 'B', 'Suspend! [C]']);
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<div>
<span>Loading A</span>
<span>Loading B</span>
</div>
<div>
<span>Loading C</span>
</div>
</Fragment>,
);
await C.resolve();
expect(Scheduler).toFlushAndYield(['A', 'B', 'C']);
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<div>
<span>A</span>
<span>B</span>
</div>
<div>
<span>C</span>
</div>
</Fragment>,
);
});
it('displays all "together" in nested SuspenseLists', async () => {
let A = createAsyncText('A');
let B = createAsyncText('B');
let C = createAsyncText('C');
function Foo() {
return (
<SuspenseList revealOrder="together">
<Suspense fallback={<Text text="Loading A" />}>
<A />
</Suspense>
<SuspenseList revealOrder="together">
<Suspense fallback={<Text text="Loading B" />}>
<B />
</Suspense>
<Suspense fallback={<Text text="Loading C" />}>
<C />
</Suspense>
</SuspenseList>
</SuspenseList>
);
}
await A.resolve();
await B.resolve();
ReactNoop.render(<Foo />);
expect(Scheduler).toFlushAndYield([
'A',
'B',
'Suspend! [C]',
'Loading C',
'Loading B',
'Loading C',
'Loading A',
'Loading B',
'Loading C',
]);
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>Loading A</span>
<span>Loading B</span>
<span>Loading C</span>
</Fragment>,
);
await C.resolve();
expect(Scheduler).toFlushAndYield(['A', 'B', 'C']);
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>A</span>
<span>B</span>
<span>C</span>
</Fragment>,
);
});
it('displays all "together" in nested SuspenseLists where the inner is default', async () => {
let A = createAsyncText('A');
let B = createAsyncText('B');
let C = createAsyncText('C');
function Foo() {
return (
<SuspenseList revealOrder="together">
<Suspense fallback={<Text text="Loading A" />}>
<A />
</Suspense>
<SuspenseList>
<Suspense fallback={<Text text="Loading B" />}>
<B />
</Suspense>
<Suspense fallback={<Text text="Loading C" />}>
<C />
</Suspense>
</SuspenseList>
</SuspenseList>
);
}
await A.resolve();
await B.resolve();
ReactNoop.render(<Foo />);
expect(Scheduler).toFlushAndYield([
'A',
'B',
'Suspend! [C]',
'Loading C',
'Loading A',
'Loading B',
'Loading C',
]);
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>Loading A</span>
<span>Loading B</span>
<span>Loading C</span>
</Fragment>,
);
await C.resolve();
expect(Scheduler).toFlushAndYield(['A', 'B', 'C']);
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>A</span>
<span>B</span>
<span>C</span>
</Fragment>,
);
});
it('avoided boundaries can be coordinate with SuspenseList', async () => {
let A = createAsyncText('A');
let B = createAsyncText('B');
let C = createAsyncText('C');
function Foo({showMore}) {
return (
<Suspense fallback={<Text text="Loading" />}>
<SuspenseList revealOrder="together">
<Suspense
unstable_avoidThisFallback={true}
fallback={<Text text="Loading A" />}>
<A />
</Suspense>
{showMore ? (
<Fragment>
<Suspense
unstable_avoidThisFallback={true}
fallback={<Text text="Loading B" />}>
<B />
</Suspense>
<Suspense
unstable_avoidThisFallback={true}
fallback={<Text text="Loading C" />}>
<C />
</Suspense>
</Fragment>
) : null}
</SuspenseList>
</Suspense>
);
}
ReactNoop.render(<Foo />);
expect(Scheduler).toFlushAndYield(['Suspend! [A]', 'Loading']);
expect(ReactNoop).toMatchRenderedOutput(<span>Loading</span>);
await A.resolve();
expect(Scheduler).toFlushAndYield(['A']);
expect(ReactNoop).toMatchRenderedOutput(<span>A</span>);
// Let's do an update that should consult the avoided boundaries.
ReactNoop.render(<Foo showMore={true} />);
expect(Scheduler).toFlushAndYield([
'A',
'Suspend! [B]',
'Loading B',
'Suspend! [C]',
'Loading C',
]);
// This will suspend, since the boundaries are avoided. Give them
// time to display their loading states.
jest.advanceTimersByTime(500);
// A is already showing content so it doesn't turn into a fallback.
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>A</span>
<span>Loading B</span>
<span>Loading C</span>
</Fragment>,
);
await B.resolve();
expect(Scheduler).toFlushAndYield(['B']);
// Even though we could now show B, we're still waiting on C.
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>A</span>
<span>Loading B</span>
<span>Loading C</span>
</Fragment>,
);
await C.resolve();
expect(Scheduler).toFlushAndYield(['B', 'C']);
expect(ReactNoop).toMatchRenderedOutput(
<Fragment>
<span>A</span>
<span>B</span>
<span>C</span>
</Fragment>,
);
});
});

View File

@ -11,6 +11,7 @@ import {
REACT_PROFILER_TYPE,
REACT_STRICT_MODE_TYPE,
REACT_SUSPENSE_TYPE,
REACT_SUSPENSE_LIST_TYPE,
} from 'shared/ReactSymbols';
import {Component, PureComponent} from './ReactBaseClasses';
@ -87,6 +88,7 @@ const React = {
Profiler: REACT_PROFILER_TYPE,
StrictMode: REACT_STRICT_MODE_TYPE,
Suspense: REACT_SUSPENSE_TYPE,
unstable_SuspenseList: REACT_SUSPENSE_LIST_TYPE,
createElement: __DEV__ ? createElementWithValidation : createElement,
cloneElement: __DEV__ ? cloneElementWithValidation : cloneElement,

View File

@ -46,6 +46,9 @@ export const REACT_FORWARD_REF_TYPE = hasSymbol
export const REACT_SUSPENSE_TYPE = hasSymbol
? Symbol.for('react.suspense')
: 0xead1;
export const REACT_SUSPENSE_LIST_TYPE = hasSymbol
? Symbol.for('react.suspense_list')
: 0xead8;
export const REACT_MEMO_TYPE = hasSymbol ? Symbol.for('react.memo') : 0xead3;
export const REACT_LAZY_TYPE = hasSymbol ? Symbol.for('react.lazy') : 0xead4;
export const REACT_EVENT_COMPONENT_TYPE = hasSymbol

View File

@ -28,7 +28,8 @@ export type WorkTag =
| 17
| 18
| 19
| 20;
| 20
| 21;
export const FunctionComponent = 0;
export const ClassComponent = 1;
@ -51,3 +52,4 @@ export const IncompleteClassComponent = 17;
export const DehydratedSuspenseComponent = 18;
export const EventComponent = 19;
export const EventTarget = 20;
export const SuspenseListComponent = 21;

View File

@ -20,6 +20,7 @@ import {
REACT_PROVIDER_TYPE,
REACT_STRICT_MODE_TYPE,
REACT_SUSPENSE_TYPE,
REACT_SUSPENSE_LIST_TYPE,
REACT_LAZY_TYPE,
REACT_EVENT_COMPONENT_TYPE,
REACT_EVENT_TARGET_TYPE,
@ -73,6 +74,8 @@ function getComponentName(type: mixed): string | null {
return 'StrictMode';
case REACT_SUSPENSE_TYPE:
return 'Suspense';
case REACT_SUSPENSE_LIST_TYPE:
return 'SuspenseList';
}
if (typeof type === 'object') {
switch (type.$$typeof) {

View File

@ -16,6 +16,7 @@ import {
REACT_PROVIDER_TYPE,
REACT_STRICT_MODE_TYPE,
REACT_SUSPENSE_TYPE,
REACT_SUSPENSE_LIST_TYPE,
REACT_MEMO_TYPE,
REACT_LAZY_TYPE,
REACT_EVENT_COMPONENT_TYPE,
@ -32,6 +33,7 @@ export default function isValidElementType(type: mixed) {
type === REACT_PROFILER_TYPE ||
type === REACT_STRICT_MODE_TYPE ||
type === REACT_SUSPENSE_TYPE ||
type === REACT_SUSPENSE_LIST_TYPE ||
(typeof type === 'object' &&
type !== null &&
(type.$$typeof === REACT_LAZY_TYPE ||