@samitouri / QOS-React / commits / c2c6ea1fde

Capture suspense boundaries with undefined fallbacks (#21854)

Ricky committed Jul 12, 2021 at 14:50 UTC c2c6ea1fdead7a33a33664675365b1575315276e
14 files changed +452 -129
packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js
+6 -6
@@ -714,7 +714,7 @@ describe('ReactDOMServerPartialHydration', () => {
714 expect(container.textContent).toBe('Hi');
715 });
716
717 - it('shows the fallback of the outer if fallback is missing', async () => {
717 + it('treats missing fallback the same as if it was defined', async () => {
718 // This is the same exact test as above but with a nested Suspense without a fallback.
719 // This should be a noop.
720 let suspend = false;
@@ -759,7 +759,8 @@ describe('ReactDOMServerPartialHydration', () => {
759 Scheduler.unstable_flushAll();
760 jest.runAllTimers();
761
762 - expect(ref.current).toBe(null);
762 + const span = container.getElementsByTagName('span')[0];
763 + expect(ref.current).toBe(span);
764
765 // Render an update, but leave it still suspended.
766 root.render(<App text="Hi" className="hi" />);
@@ -768,9 +769,9 @@ describe('ReactDOMServerPartialHydration', () => {
769 Scheduler.unstable_flushAll();
770 jest.runAllTimers();
771
771 - expect(container.getElementsByTagName('span').length).toBe(0);
772 - expect(ref.current).toBe(null);
773 - expect(container.textContent).toBe('Loading...');
772 + expect(container.getElementsByTagName('span').length).toBe(1);
773 + expect(ref.current).toBe(span);
774 + expect(container.textContent).toBe('');
775
776 // Unsuspending shows the content.
777 suspend = false;
@@ -780,7 +781,6 @@ describe('ReactDOMServerPartialHydration', () => {
781 Scheduler.unstable_flushAll();
782 jest.runAllTimers();
783
783 - const span = container.getElementsByTagName('span')[0];
784 expect(span.textContent).toBe('Hi');
785 expect(span.className).toBe('hi');
786 expect(ref.current).toBe(span);
packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js
+6 -4
@@ -471,10 +471,12 @@ describe('ReactDOMServerHydration', () => {
471 element,
472 );
473
474 - // Because this didn't have a fallback, it was hydrated as if it's
475 - // not a Suspense boundary.
476 - expect(ref.current).toBe(div);
477 - expect(element.innerHTML).toBe('<div>Hello World</div>');
474 + // The content should've been client rendered.
475 + expect(ref.current).not.toBe(div);
476 + // Unfortunately, since we don't delete the tail at the root, a duplicate will remain.
477 + expect(element.innerHTML).toBe(
478 + '<div>Hello World</div><div>Hello World</div>',
479 + );
480 });
481
482 // regression test for https://github.com/facebook/react/issues/17170
packages/react-dom/src/server/ReactPartialRenderer.js
-19
@@ -1123,25 +1123,6 @@ class ReactDOMServerRenderer {
1123 case REACT_SUSPENSE_TYPE: {
1124 if (enableSuspenseServerRenderer) {
1125 const fallback = ((nextChild: any): ReactElement).props.fallback;
1126 - if (fallback === undefined) {
1127 - // If there is no fallback, then this just behaves as a fragment.
1128 - const nextChildren = toArray(
1129 - ((nextChild: any): ReactElement).props.children,
1130 - );
1131 - const frame: Frame = {
1132 - type: null,
1133 - domNamespace: parentNamespace,
1134 - children: nextChildren,
1135 - childIndex: 0,
1136 - context: context,
1137 - footer: '',
1138 - };
1139 - if (__DEV__) {
1140 - ((frame: any): FrameDev).debugElementStack = [];
1141 - }
1142 - this.stack.push(frame);
1143 - return '';
1144 - }
1126 const fallbackChildren = toArray(fallback);
1127 const nextChildren = toArray(
1128 ((nextChild: any): ReactElement).props.children,
packages/react-reconciler/src/ReactFiberBeginWork.new.js
+14 -22
@@ -1867,12 +1867,8 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) {
1867 // This is a new mount or this boundary is already showing a fallback state.
1868 // Mark this subtree context as having at least one invisible parent that could
1869 // handle the fallback state.
1870 - // Boundaries without fallbacks or should be avoided are not considered since
1871 - // they cannot handle preferred fallback states.
1872 - if (
1873 - nextProps.fallback !== undefined &&
1874 - nextProps.unstable_avoidThisFallback !== true
1875 - ) {
1870 + // Avoided boundaries are not considered since they cannot handle preferred fallback states.
1871 + if (nextProps.unstable_avoidThisFallback !== true) {
1872 suspenseContext = addSubtreeSuspenseContext(
1873 suspenseContext,
1874 InvisibleParentSuspenseContext,
@@ -1910,22 +1906,18 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) {
1906 if (current === null) {
1907 // Initial mount
1908 // If we're currently hydrating, try to hydrate this boundary.
1913 - // But only if this has a fallback.
1914 - if (nextProps.fallback !== undefined) {
1915 - tryToClaimNextHydratableInstance(workInProgress);
1916 - // This could've been a dehydrated suspense component.
1917 - if (enableSuspenseServerRenderer) {
1918 - const suspenseState: null | SuspenseState =
1919 - workInProgress.memoizedState;
1920 - if (suspenseState !== null) {
1921 - const dehydrated = suspenseState.dehydrated;
1922 - if (dehydrated !== null) {
1923 - return mountDehydratedSuspenseComponent(
1924 - workInProgress,
1925 - dehydrated,
1926 - renderLanes,
1927 - );
1928 - }
1909 + tryToClaimNextHydratableInstance(workInProgress);
1910 + // This could've been a dehydrated suspense component.
1911 + if (enableSuspenseServerRenderer) {
1912 + const suspenseState: null | SuspenseState = workInProgress.memoizedState;
1913 + if (suspenseState !== null) {
1914 + const dehydrated = suspenseState.dehydrated;
1915 + if (dehydrated !== null) {
1916 + return mountDehydratedSuspenseComponent(
1917 + workInProgress,
1918 + dehydrated,
1919 + renderLanes,
1920 + );
1921 }
1922 }
1923 }
packages/react-reconciler/src/ReactFiberBeginWork.old.js
+14 -22
@@ -1867,12 +1867,8 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) {
1867 // This is a new mount or this boundary is already showing a fallback state.
1868 // Mark this subtree context as having at least one invisible parent that could
1869 // handle the fallback state.
1870 - // Boundaries without fallbacks or should be avoided are not considered since
1871 - // they cannot handle preferred fallback states.
1872 - if (
1873 - nextProps.fallback !== undefined &&
1874 - nextProps.unstable_avoidThisFallback !== true
1875 - ) {
1870 + // Avoided boundaries are not considered since they cannot handle preferred fallback states.
1871 + if (nextProps.unstable_avoidThisFallback !== true) {
1872 suspenseContext = addSubtreeSuspenseContext(
1873 suspenseContext,
1874 InvisibleParentSuspenseContext,
@@ -1910,22 +1906,18 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) {
1906 if (current === null) {
1907 // Initial mount
1908 // If we're currently hydrating, try to hydrate this boundary.
1913 - // But only if this has a fallback.
1914 - if (nextProps.fallback !== undefined) {
1915 - tryToClaimNextHydratableInstance(workInProgress);
1916 - // This could've been a dehydrated suspense component.
1917 - if (enableSuspenseServerRenderer) {
1918 - const suspenseState: null | SuspenseState =
1919 - workInProgress.memoizedState;
1920 - if (suspenseState !== null) {
1921 - const dehydrated = suspenseState.dehydrated;
1922 - if (dehydrated !== null) {
1923 - return mountDehydratedSuspenseComponent(
1924 - workInProgress,
1925 - dehydrated,
1926 - renderLanes,
1927 - );
1928 - }
1909 + tryToClaimNextHydratableInstance(workInProgress);
1910 + // This could've been a dehydrated suspense component.
1911 + if (enableSuspenseServerRenderer) {
1912 + const suspenseState: null | SuspenseState = workInProgress.memoizedState;
1913 + if (suspenseState !== null) {
1914 + const dehydrated = suspenseState.dehydrated;
1915 + if (dehydrated !== null) {
1916 + return mountDehydratedSuspenseComponent(
1917 + workInProgress,
1918 + dehydrated,
1919 + renderLanes,
1920 + );
1921 }
1922 }
1923 }
packages/react-reconciler/src/ReactFiberCompleteWork.new.js
+1 -3
@@ -1045,9 +1045,7 @@ function completeWork(
1045 const nextDidTimeout = nextState !== null;
1046 let prevDidTimeout = false;
1047 if (current === null) {
1048 - if (workInProgress.memoizedProps.fallback !== undefined) {
1049 - popHydrationState(workInProgress);
1050 - }
1048 + popHydrationState(workInProgress);
1049 } else {
1050 const prevState: null | SuspenseState = current.memoizedState;
1051 prevDidTimeout = prevState !== null;
packages/react-reconciler/src/ReactFiberCompleteWork.old.js
+1 -3
@@ -1045,9 +1045,7 @@ function completeWork(
1045 const nextDidTimeout = nextState !== null;
1046 let prevDidTimeout = false;
1047 if (current === null) {
1048 - if (workInProgress.memoizedProps.fallback !== undefined) {
1049 - popHydrationState(workInProgress);
1050 - }
1048 + popHydrationState(workInProgress);
1049 } else {
1050 const prevState: null | SuspenseState = current.memoizedState;
1051 prevDidTimeout = prevState !== null;
packages/react-reconciler/src/ReactFiberSuspenseComponent.new.js
-4
@@ -77,10 +77,6 @@ export function shouldCaptureSuspense(
77 return false;
78 }
79 const props = workInProgress.memoizedProps;
80 - // In order to capture, the Suspense component must have a fallback prop.
81 - if (props.fallback === undefined) {
82 - return false;
83 - }
80 // Regular boundaries always capture.
81 if (props.unstable_avoidThisFallback !== true) {
82 return true;
packages/react-reconciler/src/ReactFiberSuspenseComponent.old.js
-4
@@ -77,10 +77,6 @@ export function shouldCaptureSuspense(
77 return false;
78 }
79 const props = workInProgress.memoizedProps;
80 - // In order to capture, the Suspense component must have a fallback prop.
81 - if (props.fallback === undefined) {
82 - return false;
83 - }
80 // Regular boundaries always capture.
81 if (props.unstable_avoidThisFallback !== true) {
82 return true;
packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js
-2
@@ -19,8 +19,6 @@ let Scheduler;
19 let ReactDOMServer;
20 let act;
21
22 -// Additional tests can be found in ReactHooksWithNoopRenderer. Plan is to
23 -// gradually migrate those to this file.
22 describe('ReactHooks', () => {
23 beforeEach(() => {
24 jest.resetModules();
packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js
+4 -40
@@ -9,8 +9,6 @@ let act;
9 let TextResource;
10 let textResourceShouldFail;
11
12 -// Additional tests can be found in ReactSuspenseWithNoopRenderer. Plan is
13 -// to gradually migrate those to this file.
12 describe('ReactSuspense', () => {
13 beforeEach(() => {
14 jest.resetModules();
@@ -391,44 +389,10 @@ describe('ReactSuspense', () => {
389 expect(root).toMatchRenderedOutput('Hi');
390 });
391
394 - it('only captures if `fallback` is defined', () => {
395 - const root = ReactTestRenderer.create(
396 - <Suspense fallback={<Text text="Loading..." />}>
397 - <Suspense>
398 - <AsyncText text="Hi" ms={5000} />
399 - </Suspense>
400 - </Suspense>,
401 - {
402 - unstable_isConcurrent: true,
403 - },
404 - );
405 -
406 - expect(Scheduler).toFlushAndYield([
407 - 'Suspend! [Hi]',
408 - // The outer fallback should be rendered, because the inner one does not
409 - // have a `fallback` prop
410 - 'Loading...',
411 - ]);
412 - jest.advanceTimersByTime(1000);
413 - expect(Scheduler).toHaveYielded([]);
414 - expect(Scheduler).toFlushAndYield([]);
415 - expect(root).toMatchRenderedOutput('Loading...');
416 -
417 - jest.advanceTimersByTime(5000);
418 - expect(Scheduler).toHaveYielded(['Promise resolved [Hi]']);
419 - expect(Scheduler).toFlushAndYield(['Hi']);
420 - expect(root).toMatchRenderedOutput('Hi');
421 - });
422 -
423 - it('throws if tree suspends and none of the Suspense ancestors have a fallback', () => {
424 - ReactTestRenderer.create(
425 - <Suspense>
426 - <AsyncText text="Hi" ms={1000} />
427 - </Suspense>,
428 - {
429 - unstable_isConcurrent: true,
430 - },
431 - );
392 + it('throws if tree suspends and none of the Suspense ancestors have a boundary', () => {
393 + ReactTestRenderer.create(<AsyncText text="Hi" ms={1000} />, {
394 + unstable_isConcurrent: true,
395 + });
396
397 expect(Scheduler).toFlushAndThrow(
398 'AsyncText suspended while rendering, but no fallback UI was specified.',
packages/react-reconciler/src/__tests__/ReactSuspenseFallback-test.js new
+223
@@ -0,0 +1,223 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +let React;
10 +let ReactNoop;
11 +let Scheduler;
12 +let Suspense;
13 +let getCacheForType;
14 +let caches;
15 +let seededCache;
16 +
17 +describe('ReactSuspenseFallback', () => {
18 + beforeEach(() => {
19 + jest.resetModules();
20 +
21 + React = require('react');
22 + ReactNoop = require('react-noop-renderer');
23 + Scheduler = require('scheduler');
24 + Suspense = React.Suspense;
25 + getCacheForType = React.unstable_getCacheForType;
26 + caches = [];
27 + seededCache = null;
28 + });
29 +
30 + function createTextCache() {
31 + if (seededCache !== null) {
32 + // Trick to seed a cache before it exists.
33 + // TODO: Need a built-in API to seed data before the initial render (i.e.
34 + // not a refresh because nothing has mounted yet).
35 + const cache = seededCache;
36 + seededCache = null;
37 + return cache;
38 + }
39 +
40 + const data = new Map();
41 + const version = caches.length + 1;
42 + const cache = {
43 + version,
44 + data,
45 + resolve(text) {
46 + const record = data.get(text);
47 + if (record === undefined) {
48 + const newRecord = {
49 + status: 'resolved',
50 + value: text,
51 + };
52 + data.set(text, newRecord);
53 + } else if (record.status === 'pending') {
54 + const thenable = record.value;
55 + record.status = 'resolved';
56 + record.value = text;
57 + thenable.pings.forEach(t => t());
58 + }
59 + },
60 + reject(text, error) {
61 + const record = data.get(text);
62 + if (record === undefined) {
63 + const newRecord = {
64 + status: 'rejected',
65 + value: error,
66 + };
67 + data.set(text, newRecord);
68 + } else if (record.status === 'pending') {
69 + const thenable = record.value;
70 + record.status = 'rejected';
71 + record.value = error;
72 + thenable.pings.forEach(t => t());
73 + }
74 + },
75 + };
76 + caches.push(cache);
77 + return cache;
78 + }
79 +
80 + function readText(text) {
81 + const textCache = getCacheForType(createTextCache);
82 + const record = textCache.data.get(text);
83 + if (record !== undefined) {
84 + switch (record.status) {
85 + case 'pending':
86 + Scheduler.unstable_yieldValue(`Suspend! [${text}]`);
87 + throw record.value;
88 + case 'rejected':
89 + Scheduler.unstable_yieldValue(`Error! [${text}]`);
90 + throw record.value;
91 + case 'resolved':
92 + return textCache.version;
93 + }
94 + } else {
95 + Scheduler.unstable_yieldValue(`Suspend! [${text}]`);
96 +
97 + const thenable = {
98 + pings: [],
99 + then(resolve) {
100 + if (newRecord.status === 'pending') {
101 + thenable.pings.push(resolve);
102 + } else {
103 + Promise.resolve().then(() => resolve(newRecord.value));
104 + }
105 + },
106 + };
107 +
108 + const newRecord = {
109 + status: 'pending',
110 + value: thenable,
111 + };
112 + textCache.data.set(text, newRecord);
113 +
114 + throw thenable;
115 + }
116 + }
117 +
118 + function Text({text}) {
119 + Scheduler.unstable_yieldValue(text);
120 + return <span prop={text} />;
121 + }
122 +
123 + function AsyncText({text, showVersion}) {
124 + const version = readText(text);
125 + const fullText = showVersion ? `${text} [v${version}]` : text;
126 + Scheduler.unstable_yieldValue(fullText);
127 + return <span prop={fullText} />;
128 + }
129 +
130 + function span(prop) {
131 + return {type: 'span', children: [], prop, hidden: false};
132 + }
133 +
134 + // @gate enableCache
135 + it('suspends and shows fallback', () => {
136 + ReactNoop.render(
137 + <Suspense fallback={<Text text="Loading..." />}>
138 + <AsyncText text="A" ms={100} />
139 + </Suspense>,
140 + );
141 +
142 + expect(Scheduler).toFlushAndYield(['Suspend! [A]', 'Loading...']);
143 + expect(ReactNoop.getChildren()).toEqual([span('Loading...')]);
144 + });
145 +
146 + // @gate enableCache
147 + it('suspends and shows null fallback', () => {
148 + ReactNoop.render(
149 + <Suspense fallback={null}>
150 + <AsyncText text="A" ms={100} />
151 + </Suspense>,
152 + );
153 +
154 + expect(Scheduler).toFlushAndYield([
155 + 'Suspend! [A]',
156 + // null
157 + ]);
158 + expect(ReactNoop.getChildren()).toEqual([]);
159 + });
160 +
161 + // @gate enableCache
162 + it('suspends and shows undefined fallback', () => {
163 + ReactNoop.render(
164 + <Suspense>
165 + <AsyncText text="A" ms={100} />
166 + </Suspense>,
167 + );
168 +
169 + expect(Scheduler).toFlushAndYield([
170 + 'Suspend! [A]',
171 + // null
172 + ]);
173 + expect(ReactNoop.getChildren()).toEqual([]);
174 + });
175 +
176 + // @gate enableCache
177 + it('suspends and shows inner fallback', () => {
178 + ReactNoop.render(
179 + <Suspense fallback={<Text text="Should not show..." />}>
180 + <Suspense fallback={<Text text="Loading..." />}>
181 + <AsyncText text="A" ms={100} />
182 + </Suspense>
183 + </Suspense>,
184 + );
185 +
186 + expect(Scheduler).toFlushAndYield(['Suspend! [A]', 'Loading...']);
187 + expect(ReactNoop.getChildren()).toEqual([span('Loading...')]);
188 + });
189 +
190 + // @gate enableCache
191 + it('suspends and shows inner undefined fallback', () => {
192 + ReactNoop.render(
193 + <Suspense fallback={<Text text="Should not show..." />}>
194 + <Suspense>
195 + <AsyncText text="A" ms={100} />
196 + </Suspense>
197 + </Suspense>,
198 + );
199 +
200 + expect(Scheduler).toFlushAndYield([
201 + 'Suspend! [A]',
202 + // null
203 + ]);
204 + expect(ReactNoop.getChildren()).toEqual([]);
205 + });
206 +
207 + // @gate enableCache
208 + it('suspends and shows inner null fallback', () => {
209 + ReactNoop.render(
210 + <Suspense fallback={<Text text="Should not show..." />}>
211 + <Suspense fallback={null}>
212 + <AsyncText text="A" ms={100} />
213 + </Suspense>
214 + </Suspense>,
215 + );
216 +
217 + expect(Scheduler).toFlushAndYield([
218 + 'Suspend! [A]',
219 + // null
220 + ]);
221 + expect(ReactNoop.getChildren()).toEqual([]);
222 + });
223 +});
packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js
+83
@@ -771,6 +771,89 @@ describe('ReactSuspenseList', () => {
771 );
772 });
773
774 + it('boundaries without fallbacks can be coordinate with SuspenseList', async () => {
775 + const A = createAsyncText('A');
776 + const B = createAsyncText('B');
777 + const C = createAsyncText('C');
778 +
779 + function Foo({showMore}) {
780 + return (
781 + <Suspense fallback={<Text text="Loading" />}>
782 + <SuspenseList revealOrder="together">
783 + <Suspense>
784 + <A />
785 + </Suspense>
786 + {showMore ? (
787 + <>
788 + <Suspense>
789 + <B />
790 + </Suspense>
791 + <Suspense>
792 + <C />
793 + </Suspense>
794 + </>
795 + ) : null}
796 + </SuspenseList>
797 + </Suspense>
798 + );
799 + }
800 +
801 + ReactNoop.render(<Foo />);
802 +
803 + expect(Scheduler).toFlushAndYield([
804 + 'Suspend! [A]',
805 + // null
806 + ]);
807 +
808 + expect(ReactNoop).toMatchRenderedOutput(null);
809 +
810 + await A.resolve();
811 +
812 + expect(Scheduler).toFlushAndYield(['A']);
813 +
814 + expect(ReactNoop).toMatchRenderedOutput(<span>A</span>);
815 +
816 + // Let's do an update that should consult the avoided boundaries.
817 + ReactNoop.render(<Foo showMore={true} />);
818 +
819 + expect(Scheduler).toFlushAndYield([
820 + 'A',
821 + 'Suspend! [B]',
822 + // null
823 + 'Suspend! [C]',
824 + // null
825 + 'A',
826 + // null
827 + // null
828 + ]);
829 +
830 + // This will suspend, since the boundaries are avoided. Give them
831 + // time to display their loading states.
832 + jest.advanceTimersByTime(500);
833 +
834 + // A is already showing content so it doesn't turn into a fallback.
835 + expect(ReactNoop).toMatchRenderedOutput(<span>A</span>);
836 +
837 + await B.resolve();
838 +
839 + expect(Scheduler).toFlushAndYield(['B', 'Suspend! [C]']);
840 +
841 + // Even though we could now show B, we're still waiting on C.
842 + expect(ReactNoop).toMatchRenderedOutput(<span>A</span>);
843 +
844 + await C.resolve();
845 +
846 + expect(Scheduler).toFlushAndYield(['B', 'C']);
847 +
848 + expect(ReactNoop).toMatchRenderedOutput(
849 + <>
850 + <span>A</span>
851 + <span>B</span>
852 + <span>C</span>
853 + </>,
854 + );
855 + });
856 +
857 it('displays each items in "forwards" order', async () => {
858 const A = createAsyncText('A');
859 const B = createAsyncText('B');
packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js
+100
@@ -2303,6 +2303,55 @@ describe('ReactSuspenseWithNoopRenderer', () => {
2303 expect(ReactNoop.getChildren()).toEqual([span('A'), span('C'), span('B')]);
2304 });
2305
2306 + // @gate enableCache
2307 + it('does not show the parent fallback if the inner fallback is not defined', async () => {
2308 + function Foo({showC}) {
2309 + Scheduler.unstable_yieldValue('Foo');
2310 + return (
2311 + <Suspense fallback={<Text text="Initial load..." />}>
2312 + <Suspense>
2313 + <AsyncText text="A" />
2314 + {showC ? <AsyncText text="C" /> : null}
2315 + </Suspense>
2316 + <Text text="B" />
2317 + </Suspense>
2318 + );
2319 + }
2320 +
2321 + ReactNoop.render(<Foo />);
2322 + expect(Scheduler).toFlushAndYield([
2323 + 'Foo',
2324 + 'Suspend! [A]',
2325 + 'B',
2326 + // null
2327 + ]);
2328 + expect(ReactNoop.getChildren()).toEqual([span('B')]);
2329 +
2330 + // Eventually we resolve and show the data.
2331 + await resolveText('A');
2332 + expect(Scheduler).toFlushAndYield(['A']);
2333 + expect(ReactNoop.getChildren()).toEqual([span('A'), span('B')]);
2334 +
2335 + // Update to show C
2336 + ReactNoop.render(<Foo showC={true} />);
2337 + expect(Scheduler).toFlushAndYield([
2338 + 'Foo',
2339 + 'A',
2340 + 'Suspend! [C]',
2341 + // null
2342 + 'B',
2343 + ]);
2344 + // Flush to skip suspended time.
2345 + Scheduler.unstable_advanceTime(600);
2346 + await advanceTimers(600);
2347 + expect(ReactNoop.getChildren()).toEqual([hiddenSpan('A'), span('B')]);
2348 +
2349 + // Later we load the data.
2350 + await resolveText('C');
2351 + expect(Scheduler).toFlushAndYield(['A', 'C']);
2352 + expect(ReactNoop.getChildren()).toEqual([span('A'), span('C'), span('B')]);
2353 + });
2354 +
2355 // @gate enableCache
2356 it('favors showing the inner fallback for nested top level avoided fallback', async () => {
2357 function Foo({showB}) {
@@ -2393,6 +2442,57 @@ describe('ReactSuspenseWithNoopRenderer', () => {
2442 }
2443 });
2444
2445 + // @gate enableCache
2446 + it('keeps showing an undefined fallback if it is already showing', async () => {
2447 + function Foo({showB}) {
2448 + Scheduler.unstable_yieldValue('Foo');
2449 + return (
2450 + <Suspense fallback={<Text text="Initial load..." />}>
2451 + <Suspense fallback={undefined}>
2452 + <Text text="A" />
2453 + {showB ? (
2454 + <Suspense fallback={undefined}>
2455 + <AsyncText text="B" />
2456 + </Suspense>
2457 + ) : null}
2458 + </Suspense>
2459 + </Suspense>
2460 + );
2461 + }
2462 +
2463 + ReactNoop.render(<Foo />);
2464 + expect(Scheduler).toFlushAndYield(['Foo', 'A']);
2465 + expect(ReactNoop.getChildren()).toEqual([span('A')]);
2466 +
2467 + if (gate(flags => flags.enableSyncDefaultUpdates)) {
2468 + React.startTransition(() => {
2469 + ReactNoop.render(<Foo showB={true} />);
2470 + });
2471 + } else {
2472 + ReactNoop.render(<Foo showB={true} />);
2473 + }
2474 +
2475 + expect(Scheduler).toFlushAndYield([
2476 + 'Foo',
2477 + 'A',
2478 + 'Suspend! [B]',
2479 + // Null
2480 + ]);
2481 + // Still suspended.
2482 + expect(ReactNoop.getChildren()).toEqual([span('A')]);
2483 +
2484 + // Flush to skip suspended time.
2485 + Scheduler.unstable_advanceTime(600);
2486 + await advanceTimers(600);
2487 +
2488 + if (gate(flags => flags.enableSyncDefaultUpdates)) {
2489 + // Transitions never fall back.
2490 + expect(ReactNoop.getChildren()).toEqual([span('A')]);
2491 + } else {
2492 + expect(ReactNoop.getChildren()).toEqual([span('A')]);
2493 + }
2494 + });
2495 +
2496 // @gate enableCache
2497 it('commits a suspended idle pri render within a reasonable time', async () => {
2498 function Foo({renderContent}) {