@samitouri / QOS-React-2 / commits / 76864f7ff7

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.

Sebastian Markbåge committed Jun 19, 2019 at 19:34 UTC 76864f7ff729f8293e8e772da7ad8416d7def6b3
16 files changed +977 -15
packages/react-dom/src/__tests__/ReactDOMServerSuspense-test.internal.js
+21 -1
@@ -34,7 +34,7 @@ function initModules() {
34 };
35 }
36
37 -const {resetModules, serverRender} = ReactDOMServerIntegrationUtils(
37 +const {resetModules, serverRender, itRenders} = ReactDOMServerIntegrationUtils(
38 initModules,
39 );
40
@@ -98,4 +98,24 @@ describe('ReactDOMServerSuspense', () => {
98 '<div>Children</div><!--$!--><div>Fallback</div><!--/$-->',
99 );
100 });
101 +
102 + itRenders('a SuspenseList component and its children', async render => {
103 + const element = await render(
104 + <React.unstable_SuspenseList>
105 + <React.Suspense fallback="Loading A">
106 + <div>A</div>
107 + </React.Suspense>
108 + <React.Suspense fallback="Loading B">
109 + <div>B</div>
110 + </React.Suspense>
111 + </React.unstable_SuspenseList>,
112 + );
113 + const parent = element.parentNode;
114 + const divA = parent.children[0];
115 + expect(divA.tagName).toBe('DIV');
116 + expect(divA.textContent).toBe('A');
117 + const divB = parent.children[1];
118 + expect(divB.tagName).toBe('DIV');
119 + expect(divB.textContent).toBe('B');
120 + });
121 });
packages/react-dom/src/server/ReactPartialRenderer.js
+2
@@ -31,6 +31,7 @@ import {
31 REACT_STRICT_MODE_TYPE,
32 REACT_CONCURRENT_MODE_TYPE,
33 REACT_SUSPENSE_TYPE,
34 + REACT_SUSPENSE_LIST_TYPE,
35 REACT_PORTAL_TYPE,
36 REACT_PROFILER_TYPE,
37 REACT_PROVIDER_TYPE,
@@ -970,6 +971,7 @@ class ReactDOMServerRenderer {
971 case REACT_STRICT_MODE_TYPE:
972 case REACT_CONCURRENT_MODE_TYPE:
973 case REACT_PROFILER_TYPE:
974 + case REACT_SUSPENSE_LIST_TYPE:
975 case REACT_FRAGMENT_TYPE: {
976 const nextChildren = toArray(
977 ((nextChild: any): ReactElement).props.children,
packages/react-reconciler/src/ReactFiber.js
+31 -3
@@ -43,6 +43,7 @@ import {
43 ContextConsumer,
44 Profiler,
45 SuspenseComponent,
46 + SuspenseListComponent,
47 FunctionComponent,
48 MemoComponent,
49 SimpleMemoComponent,
@@ -75,6 +76,7 @@ import {
76 REACT_CONTEXT_TYPE,
77 REACT_CONCURRENT_MODE_TYPE,
78 REACT_SUSPENSE_TYPE,
79 + REACT_SUSPENSE_LIST_TYPE,
80 REACT_MEMO_TYPE,
81 REACT_LAZY_TYPE,
82 REACT_EVENT_COMPONENT_TYPE,
@@ -531,6 +533,13 @@ export function createFiberFromTypeAndProps(
533 return createFiberFromProfiler(pendingProps, mode, expirationTime, key);
534 case REACT_SUSPENSE_TYPE:
535 return createFiberFromSuspense(pendingProps, mode, expirationTime, key);
536 + case REACT_SUSPENSE_LIST_TYPE:
537 + return createFiberFromSuspenseList(
538 + pendingProps,
539 + mode,
540 + expirationTime,
541 + key,
542 + );
543 default: {
544 if (typeof type === 'object' && type !== null) {
545 switch (type.$$typeof) {
@@ -722,10 +731,29 @@ export function createFiberFromSuspense(
731 const fiber = createFiber(SuspenseComponent, pendingProps, key, mode);
732
733 // TODO: The SuspenseComponent fiber shouldn't have a type. It has a tag.
725 - const type = REACT_SUSPENSE_TYPE;
726 - fiber.elementType = type;
727 - fiber.type = type;
734 + // This needs to be fixed in getComponentName so that it relies on the tag
735 + // instead.
736 + fiber.type = REACT_SUSPENSE_TYPE;
737 + fiber.elementType = REACT_SUSPENSE_TYPE;
738 +
739 + fiber.expirationTime = expirationTime;
740 + return fiber;
741 +}
742
743 +export function createFiberFromSuspenseList(
744 + pendingProps: any,
745 + mode: TypeOfMode,
746 + expirationTime: ExpirationTime,
747 + key: null | string,
748 +) {
749 + const fiber = createFiber(SuspenseListComponent, pendingProps, key, mode);
750 + if (__DEV__) {
751 + // TODO: The SuspenseListComponent fiber shouldn't have a type. It has a tag.
752 + // This needs to be fixed in getComponentName so that it relies on the tag
753 + // instead.
754 + fiber.type = REACT_SUSPENSE_LIST_TYPE;
755 + }
756 + fiber.elementType = REACT_SUSPENSE_LIST_TYPE;
757 fiber.expirationTime = expirationTime;
758 return fiber;
759 }
packages/react-reconciler/src/ReactFiberBeginWork.js
+204 -5
@@ -11,7 +11,10 @@ import type {ReactProviderType, ReactContext} from 'shared/ReactTypes';
11 import type {Fiber} from './ReactFiber';
12 import type {FiberRoot} from './ReactFiberRoot';
13 import type {ExpirationTime} from './ReactFiberExpirationTime';
14 -import type {SuspenseState} from './ReactFiberSuspenseComponent';
14 +import type {
15 + SuspenseState,
16 + SuspenseListState,
17 +} from './ReactFiberSuspenseComponent';
18 import type {SuspenseContext} from './ReactFiberSuspenseContext';
19
20 import checkPropTypes from 'prop-types/checkPropTypes';
@@ -31,6 +34,7 @@ import {
34 ContextConsumer,
35 Profiler,
36 SuspenseComponent,
37 + SuspenseListComponent,
38 DehydratedSuspenseComponent,
39 MemoComponent,
40 SimpleMemoComponent,
@@ -121,6 +125,7 @@ import {
125 hasSuspenseContext,
126 setDefaultShallowSuspenseContext,
127 addSubtreeSuspenseContext,
128 + setShallowSuspenseContext,
129 } from './ReactFiberSuspenseContext';
130 import {
131 pushProvider,
@@ -128,6 +133,7 @@ import {
133 readContext,
134 prepareToReadContext,
135 calculateChangedBits,
136 + scheduleWorkOnParentPath,
137 } from './ReactFiberNewContext';
138 import {resetHooks, renderWithHooks, bailoutHooks} from './ReactFiberHooks';
139 import {stopProfilerTimerIfRunning} from './ReactProfilerTimer';
@@ -182,6 +188,7 @@ let didWarnAboutGetDerivedStateOnFunctionComponent;
188 let didWarnAboutFunctionRefs;
189 export let didWarnAboutReassigningProps;
190 let didWarnAboutMaxDuration;
191 +let didWarnAboutRevealOrder;
192
193 if (__DEV__) {
194 didWarnAboutBadClass = {};
@@ -191,6 +198,7 @@ if (__DEV__) {
198 didWarnAboutFunctionRefs = {};
199 didWarnAboutReassigningProps = false;
200 didWarnAboutMaxDuration = false;
201 + didWarnAboutRevealOrder = {};
202 }
203
204 export function reconcileChildren(
@@ -1433,6 +1441,22 @@ function validateFunctionComponentInDev(workInProgress: Fiber, Component: any) {
1441 // TODO: This is now an empty object. Should we just make it a boolean?
1442 const SUSPENDED_MARKER: SuspenseState = ({}: any);
1443
1444 +function shouldRemainOnFallback(
1445 + suspenseContext: SuspenseContext,
1446 + current: null | Fiber,
1447 + workInProgress: Fiber,
1448 +) {
1449 + // If the context is telling us that we should show a fallback, and we're not
1450 + // already showing content, then we should show the fallback instead.
1451 + return (
1452 + hasSuspenseContext(
1453 + suspenseContext,
1454 + (ForceSuspenseFallback: SuspenseContext),
1455 + ) &&
1456 + (current === null || current.memoizedState !== null)
1457 + );
1458 +}
1459 +
1460 function updateSuspenseComponent(
1461 current,
1462 workInProgress,
@@ -1455,10 +1479,7 @@ function updateSuspenseComponent(
1479
1480 if (
1481 (workInProgress.effectTag & DidCapture) !== NoEffect ||
1458 - hasSuspenseContext(
1459 - suspenseContext,
1460 - (ForceSuspenseFallback: SuspenseContext),
1461 - )
1482 + shouldRemainOnFallback(suspenseContext, current, workInProgress)
1483 ) {
1484 // Something in this boundary's subtree already suspended. Switch to
1485 // rendering the fallback children.
@@ -1918,6 +1939,162 @@ function updateDehydratedSuspenseComponent(
1939 }
1940 }
1941
1942 +function propagateSuspenseContextChange(
1943 + workInProgress: Fiber,
1944 + firstChild: null | Fiber,
1945 + renderExpirationTime: ExpirationTime,
1946 +): void {
1947 + // Mark any Suspense boundaries with fallbacks as having work to do.
1948 + // If they were previously forced into fallbacks, they may now be able
1949 + // to unblock.
1950 + let node = firstChild;
1951 + while (node !== null) {
1952 + if (node.tag === SuspenseComponent) {
1953 + const state: SuspenseState | null = node.memoizedState;
1954 + if (state !== null) {
1955 + if (node.expirationTime < renderExpirationTime) {
1956 + node.expirationTime = renderExpirationTime;
1957 + }
1958 + let alternate = node.alternate;
1959 + if (
1960 + alternate !== null &&
1961 + alternate.expirationTime < renderExpirationTime
1962 + ) {
1963 + alternate.expirationTime = renderExpirationTime;
1964 + }
1965 + scheduleWorkOnParentPath(node.return, renderExpirationTime);
1966 + }
1967 + } else if (node.child !== null) {
1968 + node.child.return = node;
1969 + node = node.child;
1970 + continue;
1971 + }
1972 + if (node === workInProgress) {
1973 + return;
1974 + }
1975 + while (node.sibling === null) {
1976 + if (node.return === null || node.return === workInProgress) {
1977 + return;
1978 + }
1979 + node = node.return;
1980 + }
1981 + node.sibling.return = node.return;
1982 + node = node.sibling;
1983 + }
1984 +}
1985 +
1986 +type SuspenseListRevealOrder = 'together' | void;
1987 +
1988 +function updateSuspenseListComponent(
1989 + current: Fiber | null,
1990 + workInProgress: Fiber,
1991 + renderExpirationTime: ExpirationTime,
1992 +) {
1993 + const nextProps = workInProgress.pendingProps;
1994 + const revealOrder: SuspenseListRevealOrder = nextProps.revealOrder;
1995 + const nextChildren = nextProps.children;
1996 +
1997 + let nextChildFibers;
1998 + if (current === null) {
1999 + nextChildFibers = mountChildFibers(
2000 + workInProgress,
2001 + null,
2002 + nextChildren,
2003 + renderExpirationTime,
2004 + );
2005 + } else {
2006 + nextChildFibers = reconcileChildFibers(
2007 + workInProgress,
2008 + current.child,
2009 + nextChildren,
2010 + renderExpirationTime,
2011 + );
2012 + }
2013 +
2014 + let suspenseContext: SuspenseContext = suspenseStackCursor.current;
2015 +
2016 + let shouldForceFallback = hasSuspenseContext(
2017 + suspenseContext,
2018 + (ForceSuspenseFallback: SuspenseContext),
2019 + );
2020 +
2021 + if ((workInProgress.effectTag & DidCapture) !== NoEffect) {
2022 + // This is the second pass. In this pass, we should force the
2023 + // fallbacks in place.
2024 + shouldForceFallback = true;
2025 + }
2026 +
2027 + let suspenseListState: null | SuspenseListState = null;
2028 +
2029 + if (shouldForceFallback) {
2030 + suspenseContext = setShallowSuspenseContext(
2031 + suspenseContext,
2032 + ForceSuspenseFallback,
2033 + );
2034 + suspenseListState = {
2035 + didSuspend: true,
2036 + };
2037 + } else {
2038 + let didForceFallback =
2039 + current !== null &&
2040 + current.memoizedState !== null &&
2041 + (current.memoizedState: SuspenseListState).didSuspend;
2042 + if (didForceFallback) {
2043 + // If we previously forced a fallback, we need to schedule work
2044 + // on any nested boundaries to let them know to try to render
2045 + // again. This is the same as context updating.
2046 + propagateSuspenseContextChange(
2047 + workInProgress,
2048 + nextChildFibers,
2049 + renderExpirationTime,
2050 + );
2051 + }
2052 + suspenseContext = setDefaultShallowSuspenseContext(suspenseContext);
2053 + }
2054 +
2055 + pushSuspenseContext(workInProgress, suspenseContext);
2056 +
2057 + if ((workInProgress.mode & BatchedMode) === NoMode) {
2058 + // Outside of batched mode, SuspenseList doesn't work so we just
2059 + // use make it a noop by treating it as the default revealOrder.
2060 + workInProgress.effectTag |= DidCapture;
2061 + workInProgress.child = nextChildFibers;
2062 + return nextChildFibers;
2063 + }
2064 +
2065 + switch (revealOrder) {
2066 + // TODO: For other reveal orders we'll need to split the nextChildFibers set.
2067 + case 'together': {
2068 + break;
2069 + }
2070 + default: {
2071 + // The default reveal order is the same as not having
2072 + // a boundary.
2073 + if (__DEV__) {
2074 + if (
2075 + revealOrder !== undefined &&
2076 + !didWarnAboutRevealOrder[revealOrder]
2077 + ) {
2078 + didWarnAboutRevealOrder[revealOrder] = true;
2079 + warning(
2080 + false,
2081 + '"%s" is not a supported revealOrder on <SuspenseList />. ' +
2082 + 'Did you mean "together"?',
2083 + revealOrder,
2084 + );
2085 + }
2086 + }
2087 + // We mark this as having captured but it really just says to the
2088 + // complete phase that we should treat this as done, whatever form
2089 + // it is in. No need for a second pass.
2090 + workInProgress.effectTag |= DidCapture;
2091 + }
2092 + }
2093 + workInProgress.memoizedState = suspenseListState;
2094 + workInProgress.child = nextChildFibers;
2095 + return nextChildFibers;
2096 +}
2097 +
2098 function updatePortalComponent(
2099 current: Fiber | null,
2100 workInProgress: Fiber,
@@ -2367,6 +2544,21 @@ function beginWork(
2544 }
2545 break;
2546 }
2547 + case SuspenseListComponent: {
2548 + // Check if the children have any pending work.
2549 + const childExpirationTime = workInProgress.childExpirationTime;
2550 + if (childExpirationTime < renderExpirationTime) {
2551 + pushSuspenseContext(workInProgress, suspenseStackCursor.current);
2552 + // None of the children have any work, so we can do a fast bailout.
2553 + return null;
2554 + }
2555 + // Try the normal path.
2556 + return updateSuspenseListComponent(
2557 + current,
2558 + workInProgress,
2559 + renderExpirationTime,
2560 + );
2561 + }
2562 case EventComponent:
2563 if (enableEventAPI) {
2564 pushHostContextForEventComponent(workInProgress);
@@ -2556,6 +2748,13 @@ function beginWork(
2748 }
2749 break;
2750 }
2751 + case SuspenseListComponent: {
2752 + return updateSuspenseListComponent(
2753 + current,
2754 + workInProgress,
2755 + renderExpirationTime,
2756 + );
2757 + }
2758 case EventComponent: {
2759 if (enableEventAPI) {
2760 return updateEventComponent(
packages/react-reconciler/src/ReactFiberCommitWork.js
+14
@@ -46,6 +46,7 @@ import {
46 SimpleMemoComponent,
47 EventComponent,
48 EventTarget,
49 + SuspenseListComponent,
50 } from 'shared/ReactWorkTags';
51 import {
52 invokeGuardedCallback,
@@ -590,6 +591,7 @@ function commitLifeCycles(
591 return;
592 }
593 case SuspenseComponent:
594 + case SuspenseListComponent:
595 case IncompleteClassComponent:
596 return;
597 case EventTarget: {
@@ -1182,6 +1184,11 @@ function commitWork(current: Fiber | null, finishedWork: Fiber): void {
1184 }
1185 case SuspenseComponent: {
1186 commitSuspenseComponent(finishedWork);
1187 + attachSuspenseRetryListeners(finishedWork);
1188 + return;
1189 + }
1190 + case SuspenseListComponent: {
1191 + attachSuspenseRetryListeners(finishedWork);
1192 return;
1193 }
1194 }
@@ -1256,6 +1263,11 @@ function commitWork(current: Fiber | null, finishedWork: Fiber): void {
1263 }
1264 case SuspenseComponent: {
1265 commitSuspenseComponent(finishedWork);
1266 + attachSuspenseRetryListeners(finishedWork);
1267 + return;
1268 + }
1269 + case SuspenseListComponent: {
1270 + attachSuspenseRetryListeners(finishedWork);
1271 return;
1272 }
1273 case IncompleteClassComponent: {
@@ -1290,7 +1302,9 @@ function commitSuspenseComponent(finishedWork: Fiber) {
1302 if (supportsMutation && primaryChildParent !== null) {
1303 hideOrUnhideAllChildren(primaryChildParent, newDidTimeout);
1304 }
1305 +}
1306
1307 +function attachSuspenseRetryListeners(finishedWork: Fiber) {
1308 // If this boundary just timed out, then it will have a set of thenables.
1309 // For each thenable, attach a listener so that when it resolves, React
1310 // attempts to re-render the boundary in the primary (pre-timeout) state.
packages/react-reconciler/src/ReactFiberCompleteWork.js
+108
@@ -36,6 +36,7 @@ import {
36 Mode,
37 Profiler,
38 SuspenseComponent,
39 + SuspenseListComponent,
40 DehydratedSuspenseComponent,
41 MemoComponent,
42 SimpleMemoComponent,
@@ -522,6 +523,84 @@ if (supportsMutation) {
523 };
524 }
525
526 +// Note this, might mutate the workInProgress passed in.
527 +function hasSuspendedChildrenAndNewContent(
528 + workInProgress: Fiber,
529 + firstChild: null | Fiber,
530 +): boolean {
531 + // Traversal to see if any of the immediately nested Suspense boundaries
532 + // are in their fallback states. I.e. something suspended in them.
533 + // And if some of them have new content that wasn't already visible.
534 + let hasSuspendedBoundaries = false;
535 + let hasNewContent = false;
536 +
537 + let node = firstChild;
538 + while (node !== null) {
539 + // TODO: Hidden subtrees should not be considered.
540 + if (node.tag === SuspenseComponent) {
541 + const state: SuspenseState | null = node.memoizedState;
542 + const isShowingFallback = state !== null;
543 + if (isShowingFallback) {
544 + hasSuspendedBoundaries = true;
545 +
546 + if (node.updateQueue !== null) {
547 + // If this is a newly suspended tree, it might not get committed as
548 + // part of the second pass. In that case nothing will subscribe to
549 + // its thennables. Instead, we'll transfer its thennables to the
550 + // SuspenseList so that it can retry if they resolve.
551 + // There might be multiple of these in the list but since we're
552 + // going to wait for all of them anyway, it doesn't really matter
553 + // which ones gets to ping. In theory we could get clever and keep
554 + // track of how many dependencies remain but it gets tricky because
555 + // in the meantime, we can add/remove/change items and dependencies.
556 + // We might bail out of the loop before finding any but that
557 + // doesn't matter since that means that the other boundaries that
558 + // we did find already has their listeners attached.
559 + workInProgress.updateQueue = node.updateQueue;
560 + workInProgress.effectTag |= Update;
561 + }
562 + } else {
563 + const current = node.alternate;
564 + const wasNotShowingContent =
565 + current === null || current.memoizedState !== null;
566 + if (wasNotShowingContent) {
567 + hasNewContent = true;
568 + }
569 + }
570 + if (hasSuspendedBoundaries && hasNewContent) {
571 + return true;
572 + }
573 + } else {
574 + // TODO: We can probably just use the information from the list and not
575 + // drill into its children just like if it was a Suspense boundary.
576 + if (node.tag === SuspenseListComponent && node.updateQueue !== null) {
577 + // If there's a nested SuspenseList, we might have transferred
578 + // the thennables set to it already so we must get it from there.
579 + workInProgress.updateQueue = node.updateQueue;
580 + workInProgress.effectTag |= Update;
581 + }
582 +
583 + if (node.child !== null) {
584 + node.child.return = node;
585 + node = node.child;
586 + continue;
587 + }
588 + }
589 + if (node === workInProgress) {
590 + return false;
591 + }
592 + while (node.sibling === null) {
593 + if (node.return === null || node.return === workInProgress) {
594 + return false;
595 + }
596 + node = node.return;
597 + }
598 + node.sibling.return = node.return;
599 + node = node.sibling;
600 + }
601 + return false;
602 +}
603 +
604 function completeWork(
605 current: Fiber | null,
606 workInProgress: Fiber,
@@ -834,6 +913,35 @@ function completeWork(
913 }
914 break;
915 }
916 + case SuspenseListComponent: {
917 + popSuspenseContext(workInProgress);
918 +
919 + if ((workInProgress.effectTag & DidCapture) === NoEffect) {
920 + // This is the first pass. We need to figure out if anything is still
921 + // suspended in the rendered set.
922 + const renderedChildren = workInProgress.child;
923 + // If new content unsuspended, but there's still some content that
924 + // didn't. Then we need to do a second pass that forces everything
925 + // to keep showing their fallbacks.
926 + const needsRerender = hasSuspendedChildrenAndNewContent(
927 + workInProgress,
928 + renderedChildren,
929 + );
930 + if (needsRerender) {
931 + // Rerender the whole list, but this time, we'll force fallbacks
932 + // to stay in place.
933 + workInProgress.effectTag |= DidCapture;
934 + // Reset the effect list before doing the second pass since that's now invalid.
935 + workInProgress.firstEffect = workInProgress.lastEffect = null;
936 + // Schedule work so we know not to bail out.
937 + workInProgress.expirationTime = renderExpirationTime;
938 + return workInProgress;
939 + }
940 + } else {
941 + workInProgress.effectTag &= ~DidCapture;
942 + }
943 + break;
944 + }
945 case EventComponent: {
946 if (enableEventAPI) {
947 popHostContext(workInProgress);
packages/react-reconciler/src/ReactFiberNewContext.js
+1 -1
@@ -155,7 +155,7 @@ export function calculateChangedBits<T>(
155 }
156 }
157
158 -function scheduleWorkOnParentPath(
158 +export function scheduleWorkOnParentPath(
159 parent: Fiber | null,
160 renderExpirationTime: ExpirationTime,
161 ) {
packages/react-reconciler/src/ReactFiberSuspenseComponent.js
+4
@@ -12,6 +12,10 @@ import type {Fiber} from './ReactFiber';
12 // TODO: This is now an empty object. Should we switch this to a boolean?
13 export type SuspenseState = {||};
14
15 +export type SuspenseListState = {|
16 + didSuspend: boolean,
17 +|};
18 +
19 export function shouldCaptureSuspense(
20 workInProgress: Fiber,
21 hasInvisibleParent: boolean,
packages/react-reconciler/src/ReactFiberUnwindWork.js
+10
@@ -17,6 +17,7 @@ import {
17 HostPortal,
18 ContextProvider,
19 SuspenseComponent,
20 + SuspenseListComponent,
21 DehydratedSuspenseComponent,
22 EventComponent,
23 EventTarget,
@@ -95,6 +96,12 @@ function unwindWork(
96 }
97 return null;
98 }
99 + case SuspenseListComponent: {
100 + popSuspenseContext(workInProgress);
101 + // SuspenseList doesn't actually catch anything. It should've been
102 + // caught by a nested boundary. If not, it should bubble through.
103 + return null;
104 + }
105 case HostPortal:
106 popHostContainer(workInProgress);
107 return null;
@@ -142,6 +149,9 @@ function unwindInterruptedWork(interruptedWork: Fiber) {
149 popSuspenseContext(interruptedWork);
150 }
151 break;
152 + case SuspenseListComponent:
153 + popSuspenseContext(interruptedWork);
154 + break;
155 case ContextProvider:
156 popProvider(interruptedWork);
157 break;
packages/react-reconciler/src/ReactFiberWorkLoop.js
+4 -4
@@ -2092,10 +2092,10 @@ export function pingSuspendedRoot(
2092 }
2093
2094 export function retryTimedOutBoundary(boundaryFiber: Fiber) {
2095 - // The boundary fiber (a Suspense component) previously timed out and was
2096 - // rendered in its fallback state. One of the promises that suspended it has
2097 - // resolved, which means at least part of the tree was likely unblocked. Try
2098 - // rendering again, at a new expiration time.
2095 + // The boundary fiber (a Suspense component or SuspenseList component)
2096 + // previously was rendered in its fallback state. One of the promises that
2097 + // suspended it has resolved, which means at least part of the tree was
2098 + // likely unblocked. Try rendering again, at a new expiration time.
2099 const currentTime = requestCurrentTime();
2100 const suspenseConfig = null; // Retries don't carry over the already committed update.
2101 const retryTime = computeExpirationForFiber(
packages/react-reconciler/src/__tests__/ReactSuspenseList-test.internal.js new
+565
@@ -0,0 +1,565 @@
1 +let React;
2 +let ReactFeatureFlags;
3 +let Fragment;
4 +let ReactNoop;
5 +let Scheduler;
6 +let Suspense;
7 +let SuspenseList;
8 +
9 +describe('ReactSuspenseList', () => {
10 + beforeEach(() => {
11 + jest.resetModules();
12 + ReactFeatureFlags = require('shared/ReactFeatureFlags');
13 + ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false;
14 + ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = false;
15 + React = require('react');
16 + Fragment = React.Fragment;
17 + ReactNoop = require('react-noop-renderer');
18 + Scheduler = require('scheduler');
19 + Suspense = React.Suspense;
20 + SuspenseList = React.unstable_SuspenseList;
21 + });
22 +
23 + function Text(props) {
24 + Scheduler.yieldValue(props.text);
25 + return <span>{props.text}</span>;
26 + }
27 +
28 + function createAsyncText(text) {
29 + let resolved = false;
30 + let Component = function() {
31 + if (!resolved) {
32 + Scheduler.yieldValue('Suspend! [' + text + ']');
33 + throw promise;
34 + }
35 + return <Text text={text} />;
36 + };
37 + let promise = new Promise(resolve => {
38 + Component.resolve = function() {
39 + resolved = true;
40 + return resolve();
41 + };
42 + });
43 + return Component;
44 + }
45 +
46 + it('warns if an unsupported revealOrder option is used', () => {
47 + function Foo() {
48 + return (
49 + <SuspenseList revealOrder="something">
50 + <Suspense fallback="Loading">Content</Suspense>
51 + </SuspenseList>
52 + );
53 + }
54 +
55 + ReactNoop.render(<Foo />);
56 +
57 + expect(() => Scheduler.flushAll()).toWarnDev([
58 + 'Warning: "something" is not a supported revealOrder on ' +
59 + '<SuspenseList />. Did you mean "together"?' +
60 + '\n in SuspenseList (at **)' +
61 + '\n in Foo (at **)',
62 + ]);
63 + });
64 +
65 + it('shows content independently by default', async () => {
66 + let A = createAsyncText('A');
67 + let B = createAsyncText('B');
68 + let C = createAsyncText('C');
69 +
70 + function Foo() {
71 + return (
72 + <SuspenseList>
73 + <Suspense fallback={<Text text="Loading A" />}>
74 + <A />
75 + </Suspense>
76 + <Suspense fallback={<Text text="Loading B" />}>
77 + <B />
78 + </Suspense>
79 + <Suspense fallback={<Text text="Loading C" />}>
80 + <C />
81 + </Suspense>
82 + </SuspenseList>
83 + );
84 + }
85 +
86 + await A.resolve();
87 +
88 + ReactNoop.render(<Foo />);
89 +
90 + expect(Scheduler).toFlushAndYield([
91 + 'A',
92 + 'Suspend! [B]',
93 + 'Loading B',
94 + 'Suspend! [C]',
95 + 'Loading C',
96 + ]);
97 +
98 + expect(ReactNoop).toMatchRenderedOutput(
99 + <Fragment>
100 + <span>A</span>
101 + <span>Loading B</span>
102 + <span>Loading C</span>
103 + </Fragment>,
104 + );
105 +
106 + await C.resolve();
107 +
108 + expect(Scheduler).toFlushAndYield(['C']);
109 +
110 + expect(ReactNoop).toMatchRenderedOutput(
111 + <Fragment>
112 + <span>A</span>
113 + <span>Loading B</span>
114 + <span>C</span>
115 + </Fragment>,
116 + );
117 +
118 + await B.resolve();
119 +
120 + expect(Scheduler).toFlushAndYield(['B']);
121 +
122 + expect(ReactNoop).toMatchRenderedOutput(
123 + <Fragment>
124 + <span>A</span>
125 + <span>B</span>
126 + <span>C</span>
127 + </Fragment>,
128 + );
129 + });
130 +
131 + it('shows content independently in legacy mode regardless of option', async () => {
132 + let A = createAsyncText('A');
133 + let B = createAsyncText('B');
134 + let C = createAsyncText('C');
135 +
136 + function Foo() {
137 + return (
138 + <SuspenseList revealOrder="together">
139 + <Suspense fallback={<Text text="Loading A" />}>
140 + <A />
141 + </Suspense>
142 + <Suspense fallback={<Text text="Loading B" />}>
143 + <B />
144 + </Suspense>
145 + <Suspense fallback={<Text text="Loading C" />}>
146 + <C />
147 + </Suspense>
148 + </SuspenseList>
149 + );
150 + }
151 +
152 + await A.resolve();
153 +
154 + ReactNoop.renderLegacySyncRoot(<Foo />);
155 +
156 + expect(Scheduler).toHaveYielded([
157 + 'A',
158 + 'Suspend! [B]',
159 + 'Loading B',
160 + 'Suspend! [C]',
161 + 'Loading C',
162 + ]);
163 +
164 + expect(ReactNoop).toMatchRenderedOutput(
165 + <Fragment>
166 + <span>A</span>
167 + <span>Loading B</span>
168 + <span>Loading C</span>
169 + </Fragment>,
170 + );
171 +
172 + await C.resolve();
173 +
174 + expect(Scheduler).toFlushAndYield(['C']);
175 +
176 + expect(ReactNoop).toMatchRenderedOutput(
177 + <Fragment>
178 + <span>A</span>
179 + <span>Loading B</span>
180 + <span>C</span>
181 + </Fragment>,
182 + );
183 +
184 + await B.resolve();
185 +
186 + expect(Scheduler).toFlushAndYield(['B']);
187 +
188 + expect(ReactNoop).toMatchRenderedOutput(
189 + <Fragment>
190 + <span>A</span>
191 + <span>B</span>
192 + <span>C</span>
193 + </Fragment>,
194 + );
195 + });
196 +
197 + it('displays all "together"', async () => {
198 + let A = createAsyncText('A');
199 + let B = createAsyncText('B');
200 + let C = createAsyncText('C');
201 +
202 + function Foo() {
203 + return (
204 + <SuspenseList revealOrder="together">
205 + <Suspense fallback={<Text text="Loading A" />}>
206 + <A />
207 + </Suspense>
208 + <Suspense fallback={<Text text="Loading B" />}>
209 + <B />
210 + </Suspense>
211 + <Suspense fallback={<Text text="Loading C" />}>
212 + <C />
213 + </Suspense>
214 + </SuspenseList>
215 + );
216 + }
217 +
218 + await A.resolve();
219 +
220 + ReactNoop.render(<Foo />);
221 +
222 + expect(Scheduler).toFlushAndYield([
223 + 'A',
224 + 'Suspend! [B]',
225 + 'Loading B',
226 + 'Suspend! [C]',
227 + 'Loading C',
228 + 'Loading A',
229 + 'Loading B',
230 + 'Loading C',
231 + ]);
232 +
233 + expect(ReactNoop).toMatchRenderedOutput(
234 + <Fragment>
235 + <span>Loading A</span>
236 + <span>Loading B</span>
237 + <span>Loading C</span>
238 + </Fragment>,
239 + );
240 +
241 + await B.resolve();
242 +
243 + expect(Scheduler).toFlushAndYield(['A', 'B', 'Suspend! [C]']);
244 +
245 + expect(ReactNoop).toMatchRenderedOutput(
246 + <Fragment>
247 + <span>Loading A</span>
248 + <span>Loading B</span>
249 + <span>Loading C</span>
250 + </Fragment>,
251 + );
252 +
253 + await C.resolve();
254 +
255 + expect(Scheduler).toFlushAndYield(['A', 'B', 'C']);
256 +
257 + expect(ReactNoop).toMatchRenderedOutput(
258 + <Fragment>
259 + <span>A</span>
260 + <span>B</span>
261 + <span>C</span>
262 + </Fragment>,
263 + );
264 + });
265 +
266 + it('displays all "together" even when nested as siblings', async () => {
267 + let A = createAsyncText('A');
268 + let B = createAsyncText('B');
269 + let C = createAsyncText('C');
270 +
271 + function Foo() {
272 + return (
273 + <SuspenseList revealOrder="together">
274 + <div>
275 + <Suspense fallback={<Text text="Loading A" />}>
276 + <A />
277 + </Suspense>
278 + <Suspense fallback={<Text text="Loading B" />}>
279 + <B />
280 + </Suspense>
281 + </div>
282 + <div>
283 + <Suspense fallback={<Text text="Loading C" />}>
284 + <C />
285 + </Suspense>
286 + </div>
287 + </SuspenseList>
288 + );
289 + }
290 +
291 + await A.resolve();
292 +
293 + ReactNoop.render(<Foo />);
294 +
295 + expect(Scheduler).toFlushAndYield([
296 + 'A',
297 + 'Suspend! [B]',
298 + 'Loading B',
299 + 'Suspend! [C]',
300 + 'Loading C',
301 + 'Loading A',
302 + 'Loading B',
303 + 'Loading C',
304 + ]);
305 +
306 + expect(ReactNoop).toMatchRenderedOutput(
307 + <Fragment>
308 + <div>
309 + <span>Loading A</span>
310 + <span>Loading B</span>
311 + </div>
312 + <div>
313 + <span>Loading C</span>
314 + </div>
315 + </Fragment>,
316 + );
317 +
318 + await B.resolve();
319 +
320 + expect(Scheduler).toFlushAndYield(['A', 'B', 'Suspend! [C]']);
321 +
322 + expect(ReactNoop).toMatchRenderedOutput(
323 + <Fragment>
324 + <div>
325 + <span>Loading A</span>
326 + <span>Loading B</span>
327 + </div>
328 + <div>
329 + <span>Loading C</span>
330 + </div>
331 + </Fragment>,
332 + );
333 +
334 + await C.resolve();
335 +
336 + expect(Scheduler).toFlushAndYield(['A', 'B', 'C']);
337 +
338 + expect(ReactNoop).toMatchRenderedOutput(
339 + <Fragment>
340 + <div>
341 + <span>A</span>
342 + <span>B</span>
343 + </div>
344 + <div>
345 + <span>C</span>
346 + </div>
347 + </Fragment>,
348 + );
349 + });
350 +
351 + it('displays all "together" in nested SuspenseLists', async () => {
352 + let A = createAsyncText('A');
353 + let B = createAsyncText('B');
354 + let C = createAsyncText('C');
355 +
356 + function Foo() {
357 + return (
358 + <SuspenseList revealOrder="together">
359 + <Suspense fallback={<Text text="Loading A" />}>
360 + <A />
361 + </Suspense>
362 + <SuspenseList revealOrder="together">
363 + <Suspense fallback={<Text text="Loading B" />}>
364 + <B />
365 + </Suspense>
366 + <Suspense fallback={<Text text="Loading C" />}>
367 + <C />
368 + </Suspense>
369 + </SuspenseList>
370 + </SuspenseList>
371 + );
372 + }
373 +
374 + await A.resolve();
375 + await B.resolve();
376 +
377 + ReactNoop.render(<Foo />);
378 +
379 + expect(Scheduler).toFlushAndYield([
380 + 'A',
381 + 'B',
382 + 'Suspend! [C]',
383 + 'Loading C',
384 + 'Loading B',
385 + 'Loading C',
386 + 'Loading A',
387 + 'Loading B',
388 + 'Loading C',
389 + ]);
390 +
391 + expect(ReactNoop).toMatchRenderedOutput(
392 + <Fragment>
393 + <span>Loading A</span>
394 + <span>Loading B</span>
395 + <span>Loading C</span>
396 + </Fragment>,
397 + );
398 +
399 + await C.resolve();
400 +
401 + expect(Scheduler).toFlushAndYield(['A', 'B', 'C']);
402 +
403 + expect(ReactNoop).toMatchRenderedOutput(
404 + <Fragment>
405 + <span>A</span>
406 + <span>B</span>
407 + <span>C</span>
408 + </Fragment>,
409 + );
410 + });
411 +
412 + it('displays all "together" in nested SuspenseLists where the inner is default', async () => {
413 + let A = createAsyncText('A');
414 + let B = createAsyncText('B');
415 + let C = createAsyncText('C');
416 +
417 + function Foo() {
418 + return (
419 + <SuspenseList revealOrder="together">
420 + <Suspense fallback={<Text text="Loading A" />}>
421 + <A />
422 + </Suspense>
423 + <SuspenseList>
424 + <Suspense fallback={<Text text="Loading B" />}>
425 + <B />
426 + </Suspense>
427 + <Suspense fallback={<Text text="Loading C" />}>
428 + <C />
429 + </Suspense>
430 + </SuspenseList>
431 + </SuspenseList>
432 + );
433 + }
434 +
435 + await A.resolve();
436 + await B.resolve();
437 +
438 + ReactNoop.render(<Foo />);
439 +
440 + expect(Scheduler).toFlushAndYield([
441 + 'A',
442 + 'B',
443 + 'Suspend! [C]',
444 + 'Loading C',
445 + 'Loading A',
446 + 'Loading B',
447 + 'Loading C',
448 + ]);
449 +
450 + expect(ReactNoop).toMatchRenderedOutput(
451 + <Fragment>
452 + <span>Loading A</span>
453 + <span>Loading B</span>
454 + <span>Loading C</span>
455 + </Fragment>,
456 + );
457 +
458 + await C.resolve();
459 +
460 + expect(Scheduler).toFlushAndYield(['A', 'B', 'C']);
461 +
462 + expect(ReactNoop).toMatchRenderedOutput(
463 + <Fragment>
464 + <span>A</span>
465 + <span>B</span>
466 + <span>C</span>
467 + </Fragment>,
468 + );
469 + });
470 +
471 + it('avoided boundaries can be coordinate with SuspenseList', async () => {
472 + let A = createAsyncText('A');
473 + let B = createAsyncText('B');
474 + let C = createAsyncText('C');
475 +
476 + function Foo({showMore}) {
477 + return (
478 + <Suspense fallback={<Text text="Loading" />}>
479 + <SuspenseList revealOrder="together">
480 + <Suspense
481 + unstable_avoidThisFallback={true}
482 + fallback={<Text text="Loading A" />}>
483 + <A />
484 + </Suspense>
485 + {showMore ? (
486 + <Fragment>
487 + <Suspense
488 + unstable_avoidThisFallback={true}
489 + fallback={<Text text="Loading B" />}>
490 + <B />
491 + </Suspense>
492 + <Suspense
493 + unstable_avoidThisFallback={true}
494 + fallback={<Text text="Loading C" />}>
495 + <C />
496 + </Suspense>
497 + </Fragment>
498 + ) : null}
499 + </SuspenseList>
500 + </Suspense>
501 + );
502 + }
503 +
504 + ReactNoop.render(<Foo />);
505 +
506 + expect(Scheduler).toFlushAndYield(['Suspend! [A]', 'Loading']);
507 +
508 + expect(ReactNoop).toMatchRenderedOutput(<span>Loading</span>);
509 +
510 + await A.resolve();
511 +
512 + expect(Scheduler).toFlushAndYield(['A']);
513 +
514 + expect(ReactNoop).toMatchRenderedOutput(<span>A</span>);
515 +
516 + // Let's do an update that should consult the avoided boundaries.
517 + ReactNoop.render(<Foo showMore={true} />);
518 +
519 + expect(Scheduler).toFlushAndYield([
520 + 'A',
521 + 'Suspend! [B]',
522 + 'Loading B',
523 + 'Suspend! [C]',
524 + 'Loading C',
525 + ]);
526 +
527 + // This will suspend, since the boundaries are avoided. Give them
528 + // time to display their loading states.
529 + jest.advanceTimersByTime(500);
530 +
531 + // A is already showing content so it doesn't turn into a fallback.
532 + expect(ReactNoop).toMatchRenderedOutput(
533 + <Fragment>
534 + <span>A</span>
535 + <span>Loading B</span>
536 + <span>Loading C</span>
537 + </Fragment>,
538 + );
539 +
540 + await B.resolve();
541 +
542 + expect(Scheduler).toFlushAndYield(['B']);
543 +
544 + // Even though we could now show B, we're still waiting on C.
545 + expect(ReactNoop).toMatchRenderedOutput(
546 + <Fragment>
547 + <span>A</span>
548 + <span>Loading B</span>
549 + <span>Loading C</span>
550 + </Fragment>,
551 + );
552 +
553 + await C.resolve();
554 +
555 + expect(Scheduler).toFlushAndYield(['B', 'C']);
556 +
557 + expect(ReactNoop).toMatchRenderedOutput(
558 + <Fragment>
559 + <span>A</span>
560 + <span>B</span>
561 + <span>C</span>
562 + </Fragment>,
563 + );
564 + });
565 +});
packages/react/src/React.js
+2
@@ -11,6 +11,7 @@ import {
11 REACT_PROFILER_TYPE,
12 REACT_STRICT_MODE_TYPE,
13 REACT_SUSPENSE_TYPE,
14 + REACT_SUSPENSE_LIST_TYPE,
15 } from 'shared/ReactSymbols';
16
17 import {Component, PureComponent} from './ReactBaseClasses';
@@ -87,6 +88,7 @@ const React = {
88 Profiler: REACT_PROFILER_TYPE,
89 StrictMode: REACT_STRICT_MODE_TYPE,
90 Suspense: REACT_SUSPENSE_TYPE,
91 + unstable_SuspenseList: REACT_SUSPENSE_LIST_TYPE,
92
93 createElement: __DEV__ ? createElementWithValidation : createElement,
94 cloneElement: __DEV__ ? cloneElementWithValidation : cloneElement,
packages/shared/ReactSymbols.js
+3
@@ -46,6 +46,9 @@ export const REACT_FORWARD_REF_TYPE = hasSymbol
46 export const REACT_SUSPENSE_TYPE = hasSymbol
47 ? Symbol.for('react.suspense')
48 : 0xead1;
49 +export const REACT_SUSPENSE_LIST_TYPE = hasSymbol
50 + ? Symbol.for('react.suspense_list')
51 + : 0xead8;
52 export const REACT_MEMO_TYPE = hasSymbol ? Symbol.for('react.memo') : 0xead3;
53 export const REACT_LAZY_TYPE = hasSymbol ? Symbol.for('react.lazy') : 0xead4;
54 export const REACT_EVENT_COMPONENT_TYPE = hasSymbol
packages/shared/ReactWorkTags.js
+3 -1
@@ -28,7 +28,8 @@ export type WorkTag =
28 | 17
29 | 18
30 | 19
31 - | 20;
31 + | 20
32 + | 21;
33
34 export const FunctionComponent = 0;
35 export const ClassComponent = 1;
@@ -51,3 +52,4 @@ export const IncompleteClassComponent = 17;
52 export const DehydratedSuspenseComponent = 18;
53 export const EventComponent = 19;
54 export const EventTarget = 20;
55 +export const SuspenseListComponent = 21;
packages/shared/getComponentName.js
+3
@@ -20,6 +20,7 @@ import {
20 REACT_PROVIDER_TYPE,
21 REACT_STRICT_MODE_TYPE,
22 REACT_SUSPENSE_TYPE,
23 + REACT_SUSPENSE_LIST_TYPE,
24 REACT_LAZY_TYPE,
25 REACT_EVENT_COMPONENT_TYPE,
26 REACT_EVENT_TARGET_TYPE,
@@ -73,6 +74,8 @@ function getComponentName(type: mixed): string | null {
74 return 'StrictMode';
75 case REACT_SUSPENSE_TYPE:
76 return 'Suspense';
77 + case REACT_SUSPENSE_LIST_TYPE:
78 + return 'SuspenseList';
79 }
80 if (typeof type === 'object') {
81 switch (type.$$typeof) {
packages/shared/isValidElementType.js
+2
@@ -16,6 +16,7 @@ import {
16 REACT_PROVIDER_TYPE,
17 REACT_STRICT_MODE_TYPE,
18 REACT_SUSPENSE_TYPE,
19 + REACT_SUSPENSE_LIST_TYPE,
20 REACT_MEMO_TYPE,
21 REACT_LAZY_TYPE,
22 REACT_EVENT_COMPONENT_TYPE,
@@ -32,6 +33,7 @@ export default function isValidElementType(type: mixed) {
33 type === REACT_PROFILER_TYPE ||
34 type === REACT_STRICT_MODE_TYPE ||
35 type === REACT_SUSPENSE_TYPE ||
36 + type === REACT_SUSPENSE_LIST_TYPE ||
37 (typeof type === 'object' &&
38 type !== null &&
39 (type.$$typeof === REACT_LAZY_TYPE ||