@samitouri / QOS-React-2 / commits / 7572e4931f

Track thenable state in work loop

This is a refactor to track the array of thenables that is preserved across replays in the work loop instead of the Thenable module. The reason is that I'm about to add additional state to the Thenable module that is specific to a particular attempt — like the current index — and is reset between replays. So it's helpful to keep the two kinds of state separate so it's clearer which state gets reset when. The array of thenables is not reset until the work-in-progress either completes or unwinds. This also makes the structure more similar to Fizz and Flight.

Andrew Clark committed Oct 22, 2022 at 19:29 UTC 7572e4931f820ad7c8aa59452ab7a40eb68843fc
7 files changed +201 -71
packages/react-reconciler/src/ReactFiberHooks.new.js
+6
@@ -102,6 +102,7 @@ import {
102 requestEventTime,
103 markSkippedUpdateLanes,
104 isInvalidExecutionContextForEventFunction,
105 + getSuspendedThenableState,
106 } from './ReactFiberWorkLoop.new';
107
108 import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber';
@@ -134,6 +135,7 @@ import {
135 import {getTreeId} from './ReactFiberTreeContext.new';
136 import {now} from './Scheduler';
137 import {
138 + prepareThenableState,
139 trackUsedThenable,
140 getPreviouslyUsedThenableAtIndex,
141 } from './ReactFiberThenable.new';
@@ -465,6 +467,9 @@ export function renderWithHooks<Props, SecondArg>(
467 : HooksDispatcherOnUpdate;
468 }
469
470 + // If this is a replay, restore the thenable state from the previous attempt.
471 + const prevThenableState = getSuspendedThenableState();
472 + prepareThenableState(prevThenableState);
473 let children = Component(props, secondArg);
474
475 // Check if there was a render phase update
@@ -506,6 +511,7 @@ export function renderWithHooks<Props, SecondArg>(
511 ? HooksDispatcherOnRerenderInDEV
512 : HooksDispatcherOnRerender;
513
514 + prepareThenableState(prevThenableState);
515 children = Component(props, secondArg);
516 } while (didScheduleRenderPhaseUpdateDuringThisPass);
517 }
packages/react-reconciler/src/ReactFiberHooks.old.js
+6
@@ -102,6 +102,7 @@ import {
102 requestEventTime,
103 markSkippedUpdateLanes,
104 isInvalidExecutionContextForEventFunction,
105 + getSuspendedThenableState,
106 } from './ReactFiberWorkLoop.old';
107
108 import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber';
@@ -134,6 +135,7 @@ import {
135 import {getTreeId} from './ReactFiberTreeContext.old';
136 import {now} from './Scheduler';
137 import {
138 + prepareThenableState,
139 trackUsedThenable,
140 getPreviouslyUsedThenableAtIndex,
141 } from './ReactFiberThenable.old';
@@ -465,6 +467,9 @@ export function renderWithHooks<Props, SecondArg>(
467 : HooksDispatcherOnUpdate;
468 }
469
470 + // If this is a replay, restore the thenable state from the previous attempt.
471 + const prevThenableState = getSuspendedThenableState();
472 + prepareThenableState(prevThenableState);
473 let children = Component(props, secondArg);
474
475 // Check if there was a render phase update
@@ -506,6 +511,7 @@ export function renderWithHooks<Props, SecondArg>(
511 ? HooksDispatcherOnRerenderInDEV
512 : HooksDispatcherOnRerender;
513
514 + prepareThenableState(prevThenableState);
515 children = Component(props, secondArg);
516 } while (didScheduleRenderPhaseUpdateDuringThisPass);
517 }
packages/react-reconciler/src/ReactFiberThenable.new.js
+43 -24
@@ -17,19 +17,49 @@ import type {
17 import ReactSharedInternals from 'shared/ReactSharedInternals';
18 const {ReactCurrentActQueue} = ReactSharedInternals;
19
20 -let suspendedThenable: Thenable<any> | null = null;
21 -let usedThenables: Array<Thenable<any> | void> | null = null;
20 +// TODO: Sparse arrays are bad for performance.
21 +export opaque type ThenableState = Array<Thenable<any> | void>;
22
23 -export function isTrackingSuspendedThenable(): boolean {
24 - return suspendedThenable !== null;
23 +let thenableState: ThenableState | null = null;
24 +
25 +export function createThenableState(): ThenableState {
26 + // The ThenableState is created the first time a component suspends. If it
27 + // suspends again, we'll reuse the same state.
28 + return [];
29 +}
30 +
31 +export function prepareThenableState(prevThenableState: ThenableState | null) {
32 + // This function is called before every function that might suspend
33 + // with `use`. Right now, that's only Hooks, but in the future we'll use the
34 + // same mechanism for unwrapping promises during reconciliation.
35 + thenableState = prevThenableState;
36 +}
37 +
38 +export function getThenableStateAfterSuspending(): ThenableState | null {
39 + // Called by the work loop so it can stash the thenable state. It will use
40 + // the state to replay the component when the promise resolves.
41 + if (
42 + thenableState !== null &&
43 + // If we only `use`-ed resolved promises, then there is no suspended state
44 + // TODO: The only reason we do this is to distinguish between throwing a
45 + // promise (old Suspense pattern) versus `use`-ing one. A better solution is
46 + // for `use` to throw a special, opaque value instead of a promise.
47 + !isThenableStateResolved(thenableState)
48 + ) {
49 + const state = thenableState;
50 + thenableState = null;
51 + return state;
52 + }
53 + return null;
54 }
55
27 -export function suspendedThenableDidResolve(): boolean {
28 - if (suspendedThenable !== null) {
29 - const status = suspendedThenable.status;
56 +export function isThenableStateResolved(thenables: ThenableState): boolean {
57 + const lastThenable = thenables[thenables.length - 1];
58 + if (lastThenable !== undefined) {
59 + const status = lastThenable.status;
60 return status === 'fulfilled' || status === 'rejected';
61 }
32 - return false;
62 + return true;
63 }
64
65 export function trackUsedThenable<T>(thenable: Thenable<T>, index: number) {
@@ -37,14 +67,12 @@ export function trackUsedThenable<T>(thenable: Thenable<T>, index: number) {
67 ReactCurrentActQueue.didUsePromise = true;
68 }
69
40 - if (usedThenables === null) {
41 - usedThenables = [thenable];
70 + if (thenableState === null) {
71 + thenableState = [thenable];
72 } else {
43 - usedThenables[index] = thenable;
73 + thenableState[index] = thenable;
74 }
75
46 - suspendedThenable = thenable;
47 -
76 // We use an expando to track the status and result of a thenable so that we
77 // can synchronously unwrap the value. Think of this as an extension of the
78 // Promise API, or a custom interface that is a superset of Thenable.
@@ -59,7 +87,6 @@ export function trackUsedThenable<T>(thenable: Thenable<T>, index: number) {
87 // this thenable, because if we keep trying it will likely infinite loop
88 // without ever resolving.
89 // TODO: Log a warning?
62 - suspendedThenable = null;
90 break;
91 default: {
92 if (typeof thenable.status === 'string') {
@@ -91,19 +118,11 @@ export function trackUsedThenable<T>(thenable: Thenable<T>, index: number) {
118 }
119 }
120
94 -export function resetWakeableStateAfterEachAttempt() {
95 - suspendedThenable = null;
96 -}
97 -
98 -export function resetThenableStateOnCompletion() {
99 - usedThenables = null;
100 -}
101 -
121 export function getPreviouslyUsedThenableAtIndex<T>(
122 index: number,
123 ): Thenable<T> | null {
105 - if (usedThenables !== null) {
106 - const thenable = usedThenables[index];
124 + if (thenableState !== null) {
125 + const thenable = thenableState[index];
126 if (thenable !== undefined) {
127 return thenable;
128 }
packages/react-reconciler/src/ReactFiberThenable.old.js
+43 -24
@@ -17,19 +17,49 @@ import type {
17 import ReactSharedInternals from 'shared/ReactSharedInternals';
18 const {ReactCurrentActQueue} = ReactSharedInternals;
19
20 -let suspendedThenable: Thenable<any> | null = null;
21 -let usedThenables: Array<Thenable<any> | void> | null = null;
20 +// TODO: Sparse arrays are bad for performance.
21 +export opaque type ThenableState = Array<Thenable<any> | void>;
22
23 -export function isTrackingSuspendedThenable(): boolean {
24 - return suspendedThenable !== null;
23 +let thenableState: ThenableState | null = null;
24 +
25 +export function createThenableState(): ThenableState {
26 + // The ThenableState is created the first time a component suspends. If it
27 + // suspends again, we'll reuse the same state.
28 + return [];
29 +}
30 +
31 +export function prepareThenableState(prevThenableState: ThenableState | null) {
32 + // This function is called before every function that might suspend
33 + // with `use`. Right now, that's only Hooks, but in the future we'll use the
34 + // same mechanism for unwrapping promises during reconciliation.
35 + thenableState = prevThenableState;
36 +}
37 +
38 +export function getThenableStateAfterSuspending(): ThenableState | null {
39 + // Called by the work loop so it can stash the thenable state. It will use
40 + // the state to replay the component when the promise resolves.
41 + if (
42 + thenableState !== null &&
43 + // If we only `use`-ed resolved promises, then there is no suspended state
44 + // TODO: The only reason we do this is to distinguish between throwing a
45 + // promise (old Suspense pattern) versus `use`-ing one. A better solution is
46 + // for `use` to throw a special, opaque value instead of a promise.
47 + !isThenableStateResolved(thenableState)
48 + ) {
49 + const state = thenableState;
50 + thenableState = null;
51 + return state;
52 + }
53 + return null;
54 }
55
27 -export function suspendedThenableDidResolve(): boolean {
28 - if (suspendedThenable !== null) {
29 - const status = suspendedThenable.status;
56 +export function isThenableStateResolved(thenables: ThenableState): boolean {
57 + const lastThenable = thenables[thenables.length - 1];
58 + if (lastThenable !== undefined) {
59 + const status = lastThenable.status;
60 return status === 'fulfilled' || status === 'rejected';
61 }
32 - return false;
62 + return true;
63 }
64
65 export function trackUsedThenable<T>(thenable: Thenable<T>, index: number) {
@@ -37,14 +67,12 @@ export function trackUsedThenable<T>(thenable: Thenable<T>, index: number) {
67 ReactCurrentActQueue.didUsePromise = true;
68 }
69
40 - if (usedThenables === null) {
41 - usedThenables = [thenable];
70 + if (thenableState === null) {
71 + thenableState = [thenable];
72 } else {
43 - usedThenables[index] = thenable;
73 + thenableState[index] = thenable;
74 }
75
46 - suspendedThenable = thenable;
47 -
76 // We use an expando to track the status and result of a thenable so that we
77 // can synchronously unwrap the value. Think of this as an extension of the
78 // Promise API, or a custom interface that is a superset of Thenable.
@@ -59,7 +87,6 @@ export function trackUsedThenable<T>(thenable: Thenable<T>, index: number) {
87 // this thenable, because if we keep trying it will likely infinite loop
88 // without ever resolving.
89 // TODO: Log a warning?
62 - suspendedThenable = null;
90 break;
91 default: {
92 if (typeof thenable.status === 'string') {
@@ -91,19 +118,11 @@ export function trackUsedThenable<T>(thenable: Thenable<T>, index: number) {
118 }
119 }
120
94 -export function resetWakeableStateAfterEachAttempt() {
95 - suspendedThenable = null;
96 -}
97 -
98 -export function resetThenableStateOnCompletion() {
99 - usedThenables = null;
100 -}
101 -
121 export function getPreviouslyUsedThenableAtIndex<T>(
122 index: number,
123 ): Thenable<T> | null {
105 - if (usedThenables !== null) {
106 - const thenable = usedThenables[index];
124 + if (thenableState !== null) {
125 + const thenable = thenableState[index];
126 if (thenable !== undefined) {
127 return thenable;
128 }
packages/react-reconciler/src/ReactFiberWorkLoop.new.js
+16 -11
@@ -22,6 +22,7 @@ import type {
22 TransitionAbort,
23 } from './ReactFiberTracingMarkerComponent.new';
24 import type {OffscreenInstance} from './ReactFiberOffscreenComponent';
25 +import type {ThenableState} from './ReactFiberThenable.new';
26
27 import {
28 warnAboutDeprecatedLifecycles,
@@ -265,10 +266,8 @@ import {
266 } from './ReactFiberAct.new';
267 import {processTransitionCallbacks} from './ReactFiberTracingMarkerComponent.new';
268 import {
268 - resetWakeableStateAfterEachAttempt,
269 - resetThenableStateOnCompletion,
270 - suspendedThenableDidResolve,
271 - isTrackingSuspendedThenable,
269 + getThenableStateAfterSuspending,
270 + isThenableStateResolved,
271 } from './ReactFiberThenable.new';
272 import {schedulePostPaintCallback} from './ReactPostPaintCallback';
273
@@ -315,6 +314,7 @@ let workInProgressRootRenderLanes: Lanes = NoLanes;
314 // immediately instead of unwinding the stack.
315 let workInProgressIsSuspended: boolean = false;
316 let workInProgressThrownValue: mixed = null;
317 +let workInProgressSuspendedThenableState: ThenableState | null = null;
318
319 // Whether a ping listener was attached during this render. This is slightly
320 // different that whether something suspended, because we don't add multiple
@@ -1686,8 +1686,6 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
1686 );
1687 interruptedWork = interruptedWork.return;
1688 }
1689 - resetWakeableStateAfterEachAttempt();
1690 - resetThenableStateOnCompletion();
1689 }
1690 workInProgressRoot = root;
1691 const rootWorkInProgress = createWorkInProgress(root.current, null);
@@ -1695,6 +1693,7 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
1693 workInProgressRootRenderLanes = renderLanes = lanes;
1694 workInProgressIsSuspended = false;
1695 workInProgressThrownValue = null;
1696 + workInProgressSuspendedThenableState = null;
1697 workInProgressRootDidAttachPingListener = false;
1698 workInProgressRootExitStatus = RootInProgress;
1699 workInProgressRootFatalError = null;
@@ -1729,6 +1728,7 @@ function handleThrow(root, thrownValue): void {
1728 // as suspending the execution of the work loop.
1729 workInProgressIsSuspended = true;
1730 workInProgressThrownValue = thrownValue;
1731 + workInProgressSuspendedThenableState = getThenableStateAfterSuspending();
1732
1733 const erroredWork = workInProgress;
1734 if (erroredWork === null) {
@@ -2014,7 +2014,7 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
2014 break;
2015 } catch (thrownValue) {
2016 handleThrow(root, thrownValue);
2017 - if (isTrackingSuspendedThenable()) {
2017 + if (workInProgressSuspendedThenableState !== null) {
2018 // If this fiber just suspended, it's possible the data is already
2019 // cached. Yield to the main thread to give it a chance to ping. If
2020 // it does, we can retry immediately without unwinding the stack.
@@ -2117,13 +2117,14 @@ function resumeSuspendedUnitOfWork(
2117 // instead of unwinding the stack. It's a separate function to keep the
2118 // additional logic out of the work loop's hot path.
2119
2120 - const wasPinged = suspendedThenableDidResolve();
2121 - resetWakeableStateAfterEachAttempt();
2120 + const wasPinged =
2121 + workInProgressSuspendedThenableState !== null &&
2122 + isThenableStateResolved(workInProgressSuspendedThenableState);
2123
2124 if (!wasPinged) {
2125 // The thenable wasn't pinged. Return to the normal work loop. This will
2126 // unwind the stack, and potentially result in showing a fallback.
2126 - resetThenableStateOnCompletion();
2127 + workInProgressSuspendedThenableState = null;
2128
2129 const returnFiber = unitOfWork.return;
2130 if (returnFiber === null || workInProgressRoot === null) {
@@ -2188,7 +2189,7 @@ function resumeSuspendedUnitOfWork(
2189 // The begin phase finished successfully without suspending. Reset the state
2190 // used to track the fiber while it was suspended. Then return to the normal
2191 // work loop.
2191 - resetThenableStateOnCompletion();
2192 + workInProgressSuspendedThenableState = null;
2193
2194 resetCurrentDebugFiberInDEV();
2195 unitOfWork.memoizedProps = unitOfWork.pendingProps;
@@ -2202,6 +2203,10 @@ function resumeSuspendedUnitOfWork(
2203 ReactCurrentOwner.current = null;
2204 }
2205
2206 +export function getSuspendedThenableState(): ThenableState | null {
2207 + return workInProgressSuspendedThenableState;
2208 +}
2209 +
2210 function completeUnitOfWork(unitOfWork: Fiber): void {
2211 // Attempt to complete the current unit of work, then move to the next
2212 // sibling. If there are no more siblings, return to the parent fiber.
packages/react-reconciler/src/ReactFiberWorkLoop.old.js
+16 -11
@@ -22,6 +22,7 @@ import type {
22 TransitionAbort,
23 } from './ReactFiberTracingMarkerComponent.old';
24 import type {OffscreenInstance} from './ReactFiberOffscreenComponent';
25 +import type {ThenableState} from './ReactFiberThenable.old';
26
27 import {
28 warnAboutDeprecatedLifecycles,
@@ -265,10 +266,8 @@ import {
266 } from './ReactFiberAct.old';
267 import {processTransitionCallbacks} from './ReactFiberTracingMarkerComponent.old';
268 import {
268 - resetWakeableStateAfterEachAttempt,
269 - resetThenableStateOnCompletion,
270 - suspendedThenableDidResolve,
271 - isTrackingSuspendedThenable,
269 + getThenableStateAfterSuspending,
270 + isThenableStateResolved,
271 } from './ReactFiberThenable.old';
272 import {schedulePostPaintCallback} from './ReactPostPaintCallback';
273
@@ -315,6 +314,7 @@ let workInProgressRootRenderLanes: Lanes = NoLanes;
314 // immediately instead of unwinding the stack.
315 let workInProgressIsSuspended: boolean = false;
316 let workInProgressThrownValue: mixed = null;
317 +let workInProgressSuspendedThenableState: ThenableState | null = null;
318
319 // Whether a ping listener was attached during this render. This is slightly
320 // different that whether something suspended, because we don't add multiple
@@ -1686,8 +1686,6 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
1686 );
1687 interruptedWork = interruptedWork.return;
1688 }
1689 - resetWakeableStateAfterEachAttempt();
1690 - resetThenableStateOnCompletion();
1689 }
1690 workInProgressRoot = root;
1691 const rootWorkInProgress = createWorkInProgress(root.current, null);
@@ -1695,6 +1693,7 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
1693 workInProgressRootRenderLanes = renderLanes = lanes;
1694 workInProgressIsSuspended = false;
1695 workInProgressThrownValue = null;
1696 + workInProgressSuspendedThenableState = null;
1697 workInProgressRootDidAttachPingListener = false;
1698 workInProgressRootExitStatus = RootInProgress;
1699 workInProgressRootFatalError = null;
@@ -1729,6 +1728,7 @@ function handleThrow(root, thrownValue): void {
1728 // as suspending the execution of the work loop.
1729 workInProgressIsSuspended = true;
1730 workInProgressThrownValue = thrownValue;
1731 + workInProgressSuspendedThenableState = getThenableStateAfterSuspending();
1732
1733 const erroredWork = workInProgress;
1734 if (erroredWork === null) {
@@ -2014,7 +2014,7 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
2014 break;
2015 } catch (thrownValue) {
2016 handleThrow(root, thrownValue);
2017 - if (isTrackingSuspendedThenable()) {
2017 + if (workInProgressSuspendedThenableState !== null) {
2018 // If this fiber just suspended, it's possible the data is already
2019 // cached. Yield to the main thread to give it a chance to ping. If
2020 // it does, we can retry immediately without unwinding the stack.
@@ -2117,13 +2117,14 @@ function resumeSuspendedUnitOfWork(
2117 // instead of unwinding the stack. It's a separate function to keep the
2118 // additional logic out of the work loop's hot path.
2119
2120 - const wasPinged = suspendedThenableDidResolve();
2121 - resetWakeableStateAfterEachAttempt();
2120 + const wasPinged =
2121 + workInProgressSuspendedThenableState !== null &&
2122 + isThenableStateResolved(workInProgressSuspendedThenableState);
2123
2124 if (!wasPinged) {
2125 // The thenable wasn't pinged. Return to the normal work loop. This will
2126 // unwind the stack, and potentially result in showing a fallback.
2126 - resetThenableStateOnCompletion();
2127 + workInProgressSuspendedThenableState = null;
2128
2129 const returnFiber = unitOfWork.return;
2130 if (returnFiber === null || workInProgressRoot === null) {
@@ -2188,7 +2189,7 @@ function resumeSuspendedUnitOfWork(
2189 // The begin phase finished successfully without suspending. Reset the state
2190 // used to track the fiber while it was suspended. Then return to the normal
2191 // work loop.
2191 - resetThenableStateOnCompletion();
2192 + workInProgressSuspendedThenableState = null;
2193
2194 resetCurrentDebugFiberInDEV();
2195 unitOfWork.memoizedProps = unitOfWork.pendingProps;
@@ -2202,6 +2203,10 @@ function resumeSuspendedUnitOfWork(
2203 ReactCurrentOwner.current = null;
2204 }
2205
2206 +export function getSuspendedThenableState(): ThenableState | null {
2207 + return workInProgressSuspendedThenableState;
2208 +}
2209 +
2210 function completeUnitOfWork(unitOfWork: Fiber): void {
2211 // Attempt to complete the current unit of work, then move to the next
2212 // sibling. If there are no more siblings, return to the parent fiber.
packages/react-reconciler/src/__tests__/ReactThenable-test.js
+71 -1
@@ -8,7 +8,7 @@ let use;
8 let Suspense;
9 let startTransition;
10
11 -describe('ReactWakeable', () => {
11 +describe('ReactThenable', () => {
12 beforeEach(() => {
13 jest.resetModules();
14
@@ -243,6 +243,76 @@ describe('ReactWakeable', () => {
243 expect(Scheduler).toHaveYielded(['Oops!', 'Oops!']);
244 });
245
246 + // @gate enableUseHook
247 + test('use(promise) in multiple components', async () => {
248 + // This tests that the state for tracking promises is reset per component.
249 + const promiseA = Promise.resolve('A');
250 + const promiseB = Promise.resolve('B');
251 + const promiseC = Promise.resolve('C');
252 + const promiseD = Promise.resolve('D');
253 +
254 + function Child({prefix}) {
255 + return <Text text={prefix + use(promiseC) + use(promiseD)} />;
256 + }
257 +
258 + function Parent() {
259 + return <Child prefix={use(promiseA) + use(promiseB)} />;
260 + }
261 +
262 + function App() {
263 + return (
264 + <Suspense fallback={<Text text="Loading..." />}>
265 + <Parent />
266 + </Suspense>
267 + );
268 + }
269 +
270 + const root = ReactNoop.createRoot();
271 + await act(async () => {
272 + startTransition(() => {
273 + root.render(<App />);
274 + });
275 + });
276 + expect(Scheduler).toHaveYielded(['ABCD']);
277 + expect(root).toMatchRenderedOutput('ABCD');
278 + });
279 +
280 + // @gate enableUseHook
281 + test('use(promise) in multiple sibling components', async () => {
282 + // This tests that the state for tracking promises is reset per component.
283 +
284 + const promiseA = {then: () => {}, status: 'pending', value: null};
285 + const promiseB = {then: () => {}, status: 'pending', value: null};
286 + const promiseC = {then: () => {}, status: 'fulfilled', value: 'C'};
287 + const promiseD = {then: () => {}, status: 'fulfilled', value: 'D'};
288 +
289 + function Sibling1({prefix}) {
290 + return <Text text={use(promiseA) + use(promiseB)} />;
291 + }
292 +
293 + function Sibling2() {
294 + return <Text text={use(promiseC) + use(promiseD)} />;
295 + }
296 +
297 + function App() {
298 + return (
299 + <Suspense fallback={<Text text="Loading..." />}>
300 + <Sibling1 />
301 + <Sibling2 />
302 + </Suspense>
303 + );
304 + }
305 +
306 + const root = ReactNoop.createRoot();
307 + await act(async () => {
308 + startTransition(() => {
309 + root.render(<App />);
310 + });
311 + });
312 + expect(Scheduler).toHaveYielded(['CD', 'Loading...']);
313 + expect(root).toMatchRenderedOutput('Loading...');
314 + });
315 +
316 // @gate enableUseHook
317 test('erroring in the same component as an uncached promise does not result in an infinite loop', async () => {
318 class ErrorBoundary extends React.Component {