@samitouri / QOS-React-2 / commits / 36426e6cb6

Allow uncached IO to stablize (#25561)

Initial draft. I need to test this more. If a promise is passed to `use`, but the I/O isn't cached, we should still be able to unwrap it. This already worked in Server Components, and during SSR. For Fiber (in the browser), before this fix the state would get lost between attempts unless the promise resolved immediately in a microtask, which requires IO to be cached. This was due to an implementation quirk of Fiber where the state is reset as soon as the stack unwinds. The workaround is to suspend the entire Fiber work loop until the promise resolves. The Server Components and SSR runtimes don't require a workaround: they can maintain multiple parallel child tasks and reuse the state indefinitely across attempts. That's ideally how Fiber should work, too, but it will require larger refactor. The downside of our approach in Fiber is that it won't "warm up" the siblings while you're suspended, but to avoid waterfalls you're supposed to hoist data fetches higher in the tree regardless. But we have other ideas for how we can add this back in the future. (Though again, this doesn't affect Server Components, which already have the ideal behavior.)

Andrew Clark committed Nov 1, 2022 at 12:00 UTC 36426e6cb6b6998cba934d239ba6274c9119118c
3 files changed +388 -45
packages/react-reconciler/src/ReactFiberWorkLoop.new.js
+115 -15
@@ -9,10 +9,13 @@
9
10 import {REACT_STRICT_MODE_TYPE} from 'shared/ReactSymbols';
11
12 -import type {Wakeable} from 'shared/ReactTypes';
12 +import type {Wakeable, Thenable} from 'shared/ReactTypes';
13 import type {Fiber, FiberRoot} from './ReactInternalTypes';
14 import type {Lanes, Lane} from './ReactFiberLane.new';
15 -import type {SuspenseState} from './ReactFiberSuspenseComponent.new';
15 +import type {
16 + SuspenseProps,
17 + SuspenseState,
18 +} from './ReactFiberSuspenseComponent.new';
19 import type {FunctionComponentUpdateQueue} from './ReactFiberHooks.new';
20 import type {EventPriority} from './ReactEventPriorities.new';
21 import type {
@@ -271,6 +274,10 @@ import {
274 isThenableStateResolved,
275 } from './ReactFiberThenable.new';
276 import {schedulePostPaintCallback} from './ReactPostPaintCallback';
277 +import {
278 + getSuspenseHandler,
279 + isBadSuspenseFallback,
280 +} from './ReactFiberSuspenseContext.new';
281
282 const ceil = Math.ceil;
283
@@ -312,7 +319,7 @@ let workInProgressRootRenderLanes: Lanes = NoLanes;
319 opaque type SuspendedReason = 0 | 1 | 2 | 3 | 4;
320 const NotSuspended: SuspendedReason = 0;
321 const SuspendedOnError: SuspendedReason = 1;
315 -// const SuspendedOnData: SuspendedReason = 2;
322 +const SuspendedOnData: SuspendedReason = 2;
323 const SuspendedOnImmediate: SuspendedReason = 3;
324 const SuspendedAndReadyToUnwind: SuspendedReason = 4;
325
@@ -706,6 +713,18 @@ export function scheduleUpdateOnFiber(
713 }
714 }
715
716 + // Check if the work loop is currently suspended and waiting for data to
717 + // finish loading.
718 + if (
719 + workInProgressSuspendedReason === SuspendedOnData &&
720 + root === workInProgressRoot
721 + ) {
722 + // The incoming update might unblock the current render. Interrupt the
723 + // current attempt and restart from the top.
724 + prepareFreshStack(root, NoLanes);
725 + markRootSuspended(root, workInProgressRootRenderLanes);
726 + }
727 +
728 // Mark that the root has a pending update.
729 markRootUpdated(root, lane, eventTime);
730
@@ -1130,6 +1149,20 @@ function performConcurrentWorkOnRoot(root, didTimeout) {
1149 if (root.callbackNode === originalCallbackNode) {
1150 // The task node scheduled for this root is the same one that's
1151 // currently executed. Need to return a continuation.
1152 + if (
1153 + workInProgressSuspendedReason === SuspendedOnData &&
1154 + workInProgressRoot === root
1155 + ) {
1156 + // Special case: The work loop is currently suspended and waiting for
1157 + // data to resolve. Unschedule the current task.
1158 + //
1159 + // TODO: The factoring is a little weird. Arguably this should be checked
1160 + // in ensureRootIsScheduled instead. I went back and forth, not totally
1161 + // sure yet.
1162 + root.callbackPriority = NoLane;
1163 + root.callbackNode = null;
1164 + return null;
1165 + }
1166 return performConcurrentWorkOnRoot.bind(null, root);
1167 }
1168 return null;
@@ -1739,7 +1772,9 @@ function handleThrow(root, thrownValue): void {
1772 // deprecate the old API in favor of `use`.
1773 thrownValue = getSuspendedThenable();
1774 workInProgressSuspendedThenableState = getThenableStateAfterSuspending();
1742 - workInProgressSuspendedReason = SuspendedOnImmediate;
1775 + workInProgressSuspendedReason = shouldAttemptToSuspendUntilDataResolves()
1776 + ? SuspendedOnData
1777 + : SuspendedOnImmediate;
1778 } else {
1779 // This is a regular error. If something earlier in the component already
1780 // suspended, we must clear the thenable state to unblock the work loop.
@@ -1796,6 +1831,48 @@ function handleThrow(root, thrownValue): void {
1831 }
1832 }
1833
1834 +function shouldAttemptToSuspendUntilDataResolves() {
1835 + // TODO: We should be able to move the
1836 + // renderDidSuspend/renderDidSuspendWithDelay logic into this function,
1837 + // instead of repeating it in the complete phase. Or something to that effect.
1838 +
1839 + if (includesOnlyRetries(workInProgressRootRenderLanes)) {
1840 + // We can always wait during a retry.
1841 + return true;
1842 + }
1843 +
1844 + // TODO: We should be able to remove the equivalent check in
1845 + // finishConcurrentRender, and rely just on this one.
1846 + if (includesOnlyTransitions(workInProgressRootRenderLanes)) {
1847 + const suspenseHandler = getSuspenseHandler();
1848 + if (suspenseHandler !== null && suspenseHandler.tag === SuspenseComponent) {
1849 + const currentSuspenseHandler = suspenseHandler.alternate;
1850 + const nextProps: SuspenseProps = suspenseHandler.memoizedProps;
1851 + if (isBadSuspenseFallback(currentSuspenseHandler, nextProps)) {
1852 + // The nearest Suspense boundary is already showing content. We should
1853 + // avoid replacing it with a fallback, and instead wait until the
1854 + // data finishes loading.
1855 + return true;
1856 + } else {
1857 + // This is not a bad fallback condition. We should show a fallback
1858 + // immediately instead of waiting for the data to resolve. This includes
1859 + // when suspending inside new trees.
1860 + return false;
1861 + }
1862 + }
1863 +
1864 + // During a transition, if there is no Suspense boundary (i.e. suspending in
1865 + // the "shell" of an application), or if we're inside a hidden tree, then
1866 + // we should wait until the data finishes loading.
1867 + return true;
1868 + }
1869 +
1870 + // For all other Lanes besides Transitions and Retries, we should not wait
1871 + // for the data to load.
1872 + // TODO: We should wait during Offscreen prerendering, too.
1873 + return false;
1874 +}
1875 +
1876 function pushDispatcher(container) {
1877 prepareRendererToRender(container);
1878 const prevDispatcher = ReactCurrentDispatcher.current;
@@ -2060,7 +2137,7 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
2137 markRenderStarted(lanes);
2138 }
2139
2063 - do {
2140 + outer: do {
2141 try {
2142 if (
2143 workInProgressSuspendedReason !== NotSuspended &&
@@ -2070,19 +2147,48 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
2147 // replay the suspended component.
2148 const unitOfWork = workInProgress;
2149 const thrownValue = workInProgressThrownValue;
2073 - workInProgressSuspendedReason = NotSuspended;
2074 - workInProgressThrownValue = null;
2150 switch (workInProgressSuspendedReason) {
2151 case SuspendedOnError: {
2152 // Unwind then continue with the normal work loop.
2153 + workInProgressSuspendedReason = NotSuspended;
2154 + workInProgressThrownValue = null;
2155 unwindSuspendedUnitOfWork(unitOfWork, thrownValue);
2156 break;
2157 }
2158 + case SuspendedOnData: {
2159 + const didResolve =
2160 + workInProgressSuspendedThenableState !== null &&
2161 + isThenableStateResolved(workInProgressSuspendedThenableState);
2162 + if (didResolve) {
2163 + workInProgressSuspendedReason = NotSuspended;
2164 + workInProgressThrownValue = null;
2165 + replaySuspendedUnitOfWork(unitOfWork, thrownValue);
2166 + } else {
2167 + // The work loop is suspended on data. We should wait for it to
2168 + // resolve before continuing to render.
2169 + const thenable: Thenable<mixed> = (workInProgressThrownValue: any);
2170 + const onResolution = () => {
2171 + ensureRootIsScheduled(root, now());
2172 + };
2173 + thenable.then(onResolution, onResolution);
2174 + break outer;
2175 + }
2176 + break;
2177 + }
2178 + case SuspendedOnImmediate: {
2179 + // If this fiber just suspended, it's possible the data is already
2180 + // cached. Yield to the main thread to give it a chance to ping. If
2181 + // it does, we can retry immediately without unwinding the stack.
2182 + workInProgressSuspendedReason = SuspendedAndReadyToUnwind;
2183 + break outer;
2184 + }
2185 default: {
2082 - const wasPinged =
2186 + workInProgressSuspendedReason = NotSuspended;
2187 + workInProgressThrownValue = null;
2188 + const didResolve =
2189 workInProgressSuspendedThenableState !== null &&
2190 isThenableStateResolved(workInProgressSuspendedThenableState);
2085 - if (wasPinged) {
2191 + if (didResolve) {
2192 replaySuspendedUnitOfWork(unitOfWork, thrownValue);
2193 } else {
2194 unwindSuspendedUnitOfWork(unitOfWork, thrownValue);
@@ -2096,12 +2202,6 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
2202 break;
2203 } catch (thrownValue) {
2204 handleThrow(root, thrownValue);
2099 - if (workInProgressSuspendedThenableState !== null) {
2100 - // If this fiber just suspended, it's possible the data is already
2101 - // cached. Yield to the main thread to give it a chance to ping. If
2102 - // it does, we can retry immediately without unwinding the stack.
2103 - break;
2104 - }
2205 }
2206 } while (true);
2207 resetContextDependencies();
packages/react-reconciler/src/ReactFiberWorkLoop.old.js
+115 -15
@@ -9,10 +9,13 @@
9
10 import {REACT_STRICT_MODE_TYPE} from 'shared/ReactSymbols';
11
12 -import type {Wakeable} from 'shared/ReactTypes';
12 +import type {Wakeable, Thenable} from 'shared/ReactTypes';
13 import type {Fiber, FiberRoot} from './ReactInternalTypes';
14 import type {Lanes, Lane} from './ReactFiberLane.old';
15 -import type {SuspenseState} from './ReactFiberSuspenseComponent.old';
15 +import type {
16 + SuspenseProps,
17 + SuspenseState,
18 +} from './ReactFiberSuspenseComponent.old';
19 import type {FunctionComponentUpdateQueue} from './ReactFiberHooks.old';
20 import type {EventPriority} from './ReactEventPriorities.old';
21 import type {
@@ -271,6 +274,10 @@ import {
274 isThenableStateResolved,
275 } from './ReactFiberThenable.old';
276 import {schedulePostPaintCallback} from './ReactPostPaintCallback';
277 +import {
278 + getSuspenseHandler,
279 + isBadSuspenseFallback,
280 +} from './ReactFiberSuspenseContext.old';
281
282 const ceil = Math.ceil;
283
@@ -312,7 +319,7 @@ let workInProgressRootRenderLanes: Lanes = NoLanes;
319 opaque type SuspendedReason = 0 | 1 | 2 | 3 | 4;
320 const NotSuspended: SuspendedReason = 0;
321 const SuspendedOnError: SuspendedReason = 1;
315 -// const SuspendedOnData: SuspendedReason = 2;
322 +const SuspendedOnData: SuspendedReason = 2;
323 const SuspendedOnImmediate: SuspendedReason = 3;
324 const SuspendedAndReadyToUnwind: SuspendedReason = 4;
325
@@ -706,6 +713,18 @@ export function scheduleUpdateOnFiber(
713 }
714 }
715
716 + // Check if the work loop is currently suspended and waiting for data to
717 + // finish loading.
718 + if (
719 + workInProgressSuspendedReason === SuspendedOnData &&
720 + root === workInProgressRoot
721 + ) {
722 + // The incoming update might unblock the current render. Interrupt the
723 + // current attempt and restart from the top.
724 + prepareFreshStack(root, NoLanes);
725 + markRootSuspended(root, workInProgressRootRenderLanes);
726 + }
727 +
728 // Mark that the root has a pending update.
729 markRootUpdated(root, lane, eventTime);
730
@@ -1130,6 +1149,20 @@ function performConcurrentWorkOnRoot(root, didTimeout) {
1149 if (root.callbackNode === originalCallbackNode) {
1150 // The task node scheduled for this root is the same one that's
1151 // currently executed. Need to return a continuation.
1152 + if (
1153 + workInProgressSuspendedReason === SuspendedOnData &&
1154 + workInProgressRoot === root
1155 + ) {
1156 + // Special case: The work loop is currently suspended and waiting for
1157 + // data to resolve. Unschedule the current task.
1158 + //
1159 + // TODO: The factoring is a little weird. Arguably this should be checked
1160 + // in ensureRootIsScheduled instead. I went back and forth, not totally
1161 + // sure yet.
1162 + root.callbackPriority = NoLane;
1163 + root.callbackNode = null;
1164 + return null;
1165 + }
1166 return performConcurrentWorkOnRoot.bind(null, root);
1167 }
1168 return null;
@@ -1739,7 +1772,9 @@ function handleThrow(root, thrownValue): void {
1772 // deprecate the old API in favor of `use`.
1773 thrownValue = getSuspendedThenable();
1774 workInProgressSuspendedThenableState = getThenableStateAfterSuspending();
1742 - workInProgressSuspendedReason = SuspendedOnImmediate;
1775 + workInProgressSuspendedReason = shouldAttemptToSuspendUntilDataResolves()
1776 + ? SuspendedOnData
1777 + : SuspendedOnImmediate;
1778 } else {
1779 // This is a regular error. If something earlier in the component already
1780 // suspended, we must clear the thenable state to unblock the work loop.
@@ -1796,6 +1831,48 @@ function handleThrow(root, thrownValue): void {
1831 }
1832 }
1833
1834 +function shouldAttemptToSuspendUntilDataResolves() {
1835 + // TODO: We should be able to move the
1836 + // renderDidSuspend/renderDidSuspendWithDelay logic into this function,
1837 + // instead of repeating it in the complete phase. Or something to that effect.
1838 +
1839 + if (includesOnlyRetries(workInProgressRootRenderLanes)) {
1840 + // We can always wait during a retry.
1841 + return true;
1842 + }
1843 +
1844 + // TODO: We should be able to remove the equivalent check in
1845 + // finishConcurrentRender, and rely just on this one.
1846 + if (includesOnlyTransitions(workInProgressRootRenderLanes)) {
1847 + const suspenseHandler = getSuspenseHandler();
1848 + if (suspenseHandler !== null && suspenseHandler.tag === SuspenseComponent) {
1849 + const currentSuspenseHandler = suspenseHandler.alternate;
1850 + const nextProps: SuspenseProps = suspenseHandler.memoizedProps;
1851 + if (isBadSuspenseFallback(currentSuspenseHandler, nextProps)) {
1852 + // The nearest Suspense boundary is already showing content. We should
1853 + // avoid replacing it with a fallback, and instead wait until the
1854 + // data finishes loading.
1855 + return true;
1856 + } else {
1857 + // This is not a bad fallback condition. We should show a fallback
1858 + // immediately instead of waiting for the data to resolve. This includes
1859 + // when suspending inside new trees.
1860 + return false;
1861 + }
1862 + }
1863 +
1864 + // During a transition, if there is no Suspense boundary (i.e. suspending in
1865 + // the "shell" of an application), or if we're inside a hidden tree, then
1866 + // we should wait until the data finishes loading.
1867 + return true;
1868 + }
1869 +
1870 + // For all other Lanes besides Transitions and Retries, we should not wait
1871 + // for the data to load.
1872 + // TODO: We should wait during Offscreen prerendering, too.
1873 + return false;
1874 +}
1875 +
1876 function pushDispatcher(container) {
1877 prepareRendererToRender(container);
1878 const prevDispatcher = ReactCurrentDispatcher.current;
@@ -2060,7 +2137,7 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
2137 markRenderStarted(lanes);
2138 }
2139
2063 - do {
2140 + outer: do {
2141 try {
2142 if (
2143 workInProgressSuspendedReason !== NotSuspended &&
@@ -2070,19 +2147,48 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
2147 // replay the suspended component.
2148 const unitOfWork = workInProgress;
2149 const thrownValue = workInProgressThrownValue;
2073 - workInProgressSuspendedReason = NotSuspended;
2074 - workInProgressThrownValue = null;
2150 switch (workInProgressSuspendedReason) {
2151 case SuspendedOnError: {
2152 // Unwind then continue with the normal work loop.
2153 + workInProgressSuspendedReason = NotSuspended;
2154 + workInProgressThrownValue = null;
2155 unwindSuspendedUnitOfWork(unitOfWork, thrownValue);
2156 break;
2157 }
2158 + case SuspendedOnData: {
2159 + const didResolve =
2160 + workInProgressSuspendedThenableState !== null &&
2161 + isThenableStateResolved(workInProgressSuspendedThenableState);
2162 + if (didResolve) {
2163 + workInProgressSuspendedReason = NotSuspended;
2164 + workInProgressThrownValue = null;
2165 + replaySuspendedUnitOfWork(unitOfWork, thrownValue);
2166 + } else {
2167 + // The work loop is suspended on data. We should wait for it to
2168 + // resolve before continuing to render.
2169 + const thenable: Thenable<mixed> = (workInProgressThrownValue: any);
2170 + const onResolution = () => {
2171 + ensureRootIsScheduled(root, now());
2172 + };
2173 + thenable.then(onResolution, onResolution);
2174 + break outer;
2175 + }
2176 + break;
2177 + }
2178 + case SuspendedOnImmediate: {
2179 + // If this fiber just suspended, it's possible the data is already
2180 + // cached. Yield to the main thread to give it a chance to ping. If
2181 + // it does, we can retry immediately without unwinding the stack.
2182 + workInProgressSuspendedReason = SuspendedAndReadyToUnwind;
2183 + break outer;
2184 + }
2185 default: {
2082 - const wasPinged =
2186 + workInProgressSuspendedReason = NotSuspended;
2187 + workInProgressThrownValue = null;
2188 + const didResolve =
2189 workInProgressSuspendedThenableState !== null &&
2190 isThenableStateResolved(workInProgressSuspendedThenableState);
2085 - if (wasPinged) {
2191 + if (didResolve) {
2192 replaySuspendedUnitOfWork(unitOfWork, thrownValue);
2193 } else {
2194 unwindSuspendedUnitOfWork(unitOfWork, thrownValue);
@@ -2096,12 +2202,6 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
2202 break;
2203 } catch (thrownValue) {
2204 handleThrow(root, thrownValue);
2099 - if (workInProgressSuspendedThenableState !== null) {
2100 - // If this fiber just suspended, it's possible the data is already
2101 - // cached. Yield to the main thread to give it a chance to ping. If
2102 - // it does, we can retry immediately without unwinding the stack.
2103 - break;
2104 - }
2205 }
2206 } while (true);
2207 resetContextDependencies();
packages/react-reconciler/src/__tests__/ReactThenable-test.js
+158 -15
@@ -7,6 +7,7 @@ let act;
7 let use;
8 let Suspense;
9 let startTransition;
10 +let pendingTextRequests;
11
12 describe('ReactThenable', () => {
13 beforeEach(() => {
@@ -19,11 +20,37 @@ describe('ReactThenable', () => {
20 use = React.use;
21 Suspense = React.Suspense;
22 startTransition = React.startTransition;
23 +
24 + pendingTextRequests = new Map();
25 });
26
24 - function Text(props) {
25 - Scheduler.unstable_yieldValue(props.text);
26 - return props.text;
27 + function resolveTextRequests(text) {
28 + const requests = pendingTextRequests.get(text);
29 + if (requests !== undefined) {
30 + pendingTextRequests.delete(text);
31 + requests.forEach(resolve => resolve(text));
32 + }
33 + }
34 +
35 + function getAsyncText(text) {
36 + // getAsyncText is completely uncached — it performs a new async operation
37 + // every time it's called. During a transition, React should be able to
38 + // unwrap it anyway.
39 + Scheduler.unstable_yieldValue(`Async text requested [${text}]`);
40 + return new Promise(resolve => {
41 + const requests = pendingTextRequests.get(text);
42 + if (requests !== undefined) {
43 + requests.push(resolve);
44 + pendingTextRequests.set(text, requests);
45 + } else {
46 + pendingTextRequests.set(text, [resolve]);
47 + }
48 + });
49 + }
50 +
51 + function Text({text}) {
52 + Scheduler.unstable_yieldValue(text);
53 + return text;
54 }
55
56 // This behavior was intentionally disabled to derisk the rollout of `use`.
@@ -32,15 +59,15 @@ describe('ReactThenable', () => {
59 // to `use`, so the extra code probably isn't worth it.
60 // @gate TODO
61 test('if suspended fiber is pinged in a microtask, retry immediately without unwinding the stack', async () => {
35 - let resolved = false;
62 + let fulfilled = false;
63 function Async() {
37 - if (resolved) {
64 + if (fulfilled) {
65 return <Text text="Async" />;
66 }
67 Scheduler.unstable_yieldValue('Suspend!');
68 throw Promise.resolve().then(() => {
69 Scheduler.unstable_yieldValue('Resolve in microtask');
43 - resolved = true;
70 + fulfilled = true;
71 });
72 }
73
@@ -71,15 +98,15 @@ describe('ReactThenable', () => {
98 });
99
100 test('if suspended fiber is pinged in a microtask, it does not block a transition from completing', async () => {
74 - let resolved = false;
101 + let fulfilled = false;
102 function Async() {
76 - if (resolved) {
103 + if (fulfilled) {
104 return <Text text="Async" />;
105 }
106 Scheduler.unstable_yieldValue('Suspend!');
107 throw Promise.resolve().then(() => {
108 Scheduler.unstable_yieldValue('Resolve in microtask');
82 - resolved = true;
109 + fulfilled = true;
110 });
111 }
112
@@ -101,13 +128,13 @@ describe('ReactThenable', () => {
128 expect(root).toMatchRenderedOutput('Async');
129 });
130
104 - test('does not infinite loop if already resolved thenable is thrown', async () => {
105 - // An already resolved promise should never be thrown. Since it already
106 - // resolved, we shouldn't bother trying to render again — doing so would
131 + test('does not infinite loop if already fulfilled thenable is thrown', async () => {
132 + // An already fulfilled promise should never be thrown. Since it already
133 + // fulfilled, we shouldn't bother trying to render again — doing so would
134 // likely lead to an infinite loop. This scenario should only happen if a
135 // userspace Suspense library makes an implementation mistake.
136
110 - // Create an already resolved thenable
137 + // Create an already fulfilled thenable
138 const thenable = {
139 then(ping) {},
140 status: 'fulfilled',
@@ -120,7 +147,7 @@ describe('ReactThenable', () => {
147 throw new Error('Infinite loop detected');
148 }
149 Scheduler.unstable_yieldValue('Suspend!');
123 - // This thenable should never be thrown because it already resolved.
150 + // This thenable should never be thrown because it already fulfilled.
151 // But if it is thrown, React should handle it gracefully.
152 throw thenable;
153 }
@@ -365,7 +392,7 @@ describe('ReactThenable', () => {
392 expect(Scheduler).toHaveYielded([
393 // First attempt. The uncached promise suspends.
394 'Suspend! [Async]',
368 - // Because the promise already resolved, we're able to unwrap the value
395 + // Because the promise already fulfilled, we're able to unwrap the value
396 // immediately in a microtask.
397 //
398 // Then we proceed to the rest of the component, which throws an error.
@@ -497,4 +524,120 @@ describe('ReactThenable', () => {
524 );
525 }
526 });
527 +
528 + // @gate enableUseHook
529 + test('during a transition, can unwrap async operations even if nothing is cached', async () => {
530 + function App() {
531 + return <Text text={use(getAsyncText('Async'))} />;
532 + }
533 +
534 + const root = ReactNoop.createRoot();
535 + await act(async () => {
536 + root.render(
537 + <Suspense fallback={<Text text="Loading..." />}>
538 + <Text text="(empty)" />
539 + </Suspense>,
540 + );
541 + });
542 + expect(Scheduler).toHaveYielded(['(empty)']);
543 + expect(root).toMatchRenderedOutput('(empty)');
544 +
545 + await act(async () => {
546 + startTransition(() => {
547 + root.render(
548 + <Suspense fallback={<Text text="Loading..." />}>
549 + <App />
550 + </Suspense>,
551 + );
552 + });
553 + });
554 + expect(Scheduler).toHaveYielded(['Async text requested [Async]']);
555 + expect(root).toMatchRenderedOutput('(empty)');
556 +
557 + await act(async () => {
558 + resolveTextRequests('Async');
559 + });
560 + expect(Scheduler).toHaveYielded(['Async text requested [Async]', 'Async']);
561 + expect(root).toMatchRenderedOutput('Async');
562 + });
563 +
564 + // @gate enableUseHook
565 + test("does not prevent a Suspense fallback from showing if it's a new boundary, even during a transition", async () => {
566 + function App() {
567 + return <Text text={use(getAsyncText('Async'))} />;
568 + }
569 +
570 + const root = ReactNoop.createRoot();
571 + await act(async () => {
572 + startTransition(() => {
573 + root.render(
574 + <Suspense fallback={<Text text="Loading..." />}>
575 + <App />
576 + </Suspense>,
577 + );
578 + });
579 + });
580 + // Even though the initial render was a transition, it shows a fallback.
581 + expect(Scheduler).toHaveYielded([
582 + 'Async text requested [Async]',
583 + 'Loading...',
584 + ]);
585 + expect(root).toMatchRenderedOutput('Loading...');
586 +
587 + // Resolve the original data
588 + await act(async () => {
589 + resolveTextRequests('Async');
590 + });
591 + // During the retry, a fresh request is initiated. Now we must wait for this
592 + // one to finish.
593 + // TODO: This is awkward. Intuitively, you might expect for `act` to wait
594 + // until the new request has finished loading. But if it's mock IO, as in
595 + // this test, how would the developer be able to imperatively flush it if it
596 + // wasn't initiated until the current `act` call? Can't think of a better
597 + // strategy at the moment.
598 + expect(Scheduler).toHaveYielded(['Async text requested [Async]']);
599 + expect(root).toMatchRenderedOutput('Loading...');
600 +
601 + // Flush the second request.
602 + await act(async () => {
603 + resolveTextRequests('Async');
604 + });
605 + // This time it finishes because it was during a retry.
606 + expect(Scheduler).toHaveYielded(['Async text requested [Async]', 'Async']);
607 + expect(root).toMatchRenderedOutput('Async');
608 + });
609 +
610 + // @gate enableUseHook
611 + test('when waiting for data to resolve, a fresh update will trigger a restart', async () => {
612 + function App() {
613 + return <Text text={use(getAsyncText('Will never resolve'))} />;
614 + }
615 +
616 + const root = ReactNoop.createRoot();
617 + await act(async () => {
618 + root.render(<Suspense fallback={<Text text="Loading..." />} />);
619 + });
620 +
621 + await act(async () => {
622 + startTransition(() => {
623 + root.render(
624 + <Suspense fallback={<Text text="Loading..." />}>
625 + <App />
626 + </Suspense>,
627 + );
628 + });
629 + });
630 + expect(Scheduler).toHaveYielded([
631 + 'Async text requested [Will never resolve]',
632 + ]);
633 +
634 + await act(async () => {
635 + root.render(
636 + <Suspense fallback={<Text text="Loading..." />}>
637 + <Text text="Something different" />
638 + </Suspense>,
639 + );
640 + });
641 + expect(Scheduler).toHaveYielded(['Something different']);
642 + });
643 });