Don't prerender siblings of suspended component (#26380)

Today if something suspends, React will continue rendering the siblings
of that component.

Our original rationale for prerendering the siblings of a suspended
component was to initiate any lazy fetches that they might contain. This
was when we were more bullish about lazy fetching being a good idea some
of the time (when combined with prefetching), as opposed to our latest
thinking, which is that it's almost always a bad idea.

Another rationale for the original behavior was that the render was I/O
bound, anyway, so we might as do some extra work in the meantime. But
this was before we had the concept of instant loading states: when
navigating to a new screen, it's better to show a loading state as soon
as you can (often a skeleton UI), rather than delay the transition.
(There are still cases where we block the render, when a suitable
loading state is not available; it's just not _all_ cases where
something suspends.) So the biggest issue with our existing
implementation is that the prerendering of the siblings happens within
the same render pass as the one that suspended — _before_ the loading
state appears.

What we should do instead is immediately unwind the stack as soon as
something suspends, to unblock the loading state.

If we want to preserve the ability to prerender the siblings, what we
could do is schedule special render pass immediately after the fallback
is displayed. This is likely what we'll do in the future. However, in
the new implementation of `use`, there's another reason we don't
prerender siblings: so we can preserve the state of the stack when
something suspends, and resume where we left of when the promise
resolves without replaying the parents. The only way to do this
currently is to suspend the entire work loop. Fiber does not currently
support rendering multiple siblings in "parallel". Once you move onto
the next sibling, the stack of the previous sibling is discarded and
cannot be restored. We do plan to implement this feature, but it will
require a not-insignificant refactor.

Given that lazy data fetching is already bad for performance, the best
trade off for now seems to be to disable prerendering of siblings. This
gives us the best performance characteristics when you're following best
practices (i.e. hoist data fetches to Server Components or route
loaders), at the expense of making an already bad pattern a bit worse.

Later, when we implement resumable context stacks, we can reenable
sibling prerendering. Though even then the use case will mostly be to
prerender the CPU-bound work, not lazy fetches.
This commit is contained in:
Andrew Clark
2023-03-21 10:24:56 -04:00
committed by GitHub
parent 75b82e0272
commit 0db17469ab
33 changed files with 476 additions and 487 deletions
@@ -99,13 +99,9 @@ describe('ReactInternalTestUtils', () => {
assertLog([
'A',
'B',
'C',
'D',
// React will try one more time before giving up.
'A',
'B',
'C',
'D',
]);
});
@@ -208,19 +208,19 @@ describe('ReactCache', () => {
unstable_isConcurrent: true,
},
);
await waitForAll([
'Suspend! [1]',
'Suspend! [2]',
'Suspend! [3]',
'Loading...',
]);
await waitForAll(['Suspend! [1]', 'Loading...']);
jest.advanceTimersByTime(100);
assertLog([
'Promise resolved [1]',
'Promise resolved [2]',
'Promise resolved [3]',
]);
assertLog(['Promise resolved [1]']);
await waitForAll([1, 'Suspend! [2]']);
jest.advanceTimersByTime(100);
assertLog(['Promise resolved [2]']);
await waitForAll([1, 2, 'Suspend! [3]']);
jest.advanceTimersByTime(100);
assertLog(['Promise resolved [3]']);
await waitForAll([1, 2, 3]);
expect(root).toMatchRenderedOutput('123');
// Render 1, 4, 5
@@ -232,10 +232,16 @@ describe('ReactCache', () => {
</Suspense>,
);
await waitForAll([1, 'Suspend! [4]', 'Suspend! [5]', 'Loading...']);
await waitForAll([1, 'Suspend! [4]', 'Loading...']);
jest.advanceTimersByTime(100);
assertLog(['Promise resolved [4]', 'Promise resolved [5]']);
assertLog(['Promise resolved [4]']);
await waitForAll([1, 4, 'Suspend! [5]', 'Loading...']);
jest.advanceTimersByTime(100);
assertLog(['Promise resolved [5]']);
await waitForAll([1, 4, 5]);
expect(root).toMatchRenderedOutput('145');
// We've now rendered values 1, 2, 3, 4, 5, over our limit of 3. The least
@@ -254,11 +260,14 @@ describe('ReactCache', () => {
1,
// 2 and 3 suspend because they were evicted from the cache
'Suspend! [2]',
'Suspend! [3]',
'Loading...',
]);
jest.advanceTimersByTime(100);
assertLog(['Promise resolved [2]', 'Promise resolved [3]']);
assertLog(['Promise resolved [2]']);
await waitForAll([1, 2, 'Suspend! [3]', 'Loading...']);
jest.advanceTimersByTime(100);
assertLog(['Promise resolved [3]']);
await waitForAll([1, 2, 3]);
expect(root).toMatchRenderedOutput('123');
});
+110 -109
View File
@@ -2949,9 +2949,6 @@ describe('ReactDOMFizzServer', () => {
// blocking the main thread.
shouldThrow = false;
await waitForAll([
// Finish initial render attempt
'B',
// Render again, synchronously
'A',
'B',
@@ -3865,8 +3862,6 @@ describe('ReactDOMFizzServer', () => {
},
});
await waitForAll([
'Logged recoverable error: Text content does not match server-rendered HTML.',
'Logged recoverable error: Text content does not match server-rendered HTML.',
'Logged recoverable error: Text content does not match server-rendered HTML.',
'Logged recoverable error: There was an error while hydrating this Suspense boundary. Switched to client rendering.',
]);
@@ -3902,32 +3897,32 @@ describe('ReactDOMFizzServer', () => {
it('supresses hydration warnings when an error occurs within a Suspense boundary', async () => {
let isClient = false;
let shouldThrow = true;
function ThrowUntilOnClient({children}) {
if (isClient && shouldThrow) {
throw new Error('uh oh');
}
function ThrowWhenHydrating({children}) {
// This is a trick to only throw if we're hydrating, because
// useSyncExternalStore calls getServerSnapshot instead of the regular
// getSnapshot in that case.
useSyncExternalStore(
() => {},
t => t,
() => {
if (isClient) {
throw new Error('uh oh');
}
},
);
return children;
}
function StopThrowingOnClient() {
if (isClient) {
shouldThrow = false;
}
return null;
}
const App = () => {
return (
<div>
<Suspense fallback={<h1>Loading...</h1>}>
<ThrowUntilOnClient>
<ThrowWhenHydrating>
<h1>one</h1>
</ThrowUntilOnClient>
</ThrowWhenHydrating>
<h2>two</h2>
<h3>{isClient ? 'five' : 'three'}</h3>
<StopThrowingOnClient />
</Suspense>
</div>
);
@@ -3955,8 +3950,6 @@ describe('ReactDOMFizzServer', () => {
});
await waitForAll([
'Logged recoverable error: uh oh',
'Logged recoverable error: Hydration failed because the initial UI does not match what was rendered on the server.',
'Logged recoverable error: Hydration failed because the initial UI does not match what was rendered on the server.',
'Logged recoverable error: There was an error while hydrating this Suspense boundary. Switched to client rendering.',
]);
@@ -3986,37 +3979,37 @@ describe('ReactDOMFizzServer', () => {
mockError(...args.map(normalizeCodeLocInfo));
};
let isClient = false;
let shouldThrow = true;
function ThrowUntilOnClient({children, message}) {
if (isClient && shouldThrow) {
Scheduler.log('throwing: ' + message);
throw new Error(message);
}
function ThrowWhenHydrating({children, message}) {
// This is a trick to only throw if we're hydrating, because
// useSyncExternalStore calls getServerSnapshot instead of the regular
// getSnapshot in that case.
useSyncExternalStore(
() => {},
t => t,
() => {
if (isClient) {
Scheduler.log('throwing: ' + message);
throw new Error(message);
}
},
);
return children;
}
function StopThrowingOnClient() {
if (isClient) {
shouldThrow = false;
}
return null;
}
const App = () => {
return (
<div>
<Suspense fallback={<h1>Loading...</h1>}>
<ThrowUntilOnClient message="first error">
<ThrowWhenHydrating message="first error">
<h1>one</h1>
</ThrowUntilOnClient>
<ThrowUntilOnClient message="second error">
</ThrowWhenHydrating>
<ThrowWhenHydrating message="second error">
<h2>two</h2>
</ThrowUntilOnClient>
<ThrowUntilOnClient message="third error">
</ThrowWhenHydrating>
<ThrowWhenHydrating message="third error">
<h3>three</h3>
</ThrowUntilOnClient>
<StopThrowingOnClient />
</ThrowWhenHydrating>
</Suspense>
</div>
);
@@ -4047,14 +4040,10 @@ describe('ReactDOMFizzServer', () => {
'throwing: first error',
// this repeated first error is the invokeGuardedCallback throw
'throwing: first error',
// these are actually thrown during render but no iGC repeat and no queueing as hydration errors
'throwing: second error',
'throwing: third error',
// all hydration errors are still queued
// onRecoverableError because the UI recovered without surfacing the
// error to the user.
'Logged recoverable error: first error',
'Logged recoverable error: second error',
'Logged recoverable error: third error',
// other recoverable errors are queued as hydration errors
'Logged recoverable error: There was an error while hydrating this Suspense boundary. Switched to client rendering.',
]);
// These Uncaught error calls are the error reported by the runtime (jsdom here, browser in actual use)
@@ -4081,6 +4070,7 @@ describe('ReactDOMFizzServer', () => {
}
});
// @gate __DEV__
it('does not invokeGuardedCallback for errors after a preceding fiber suspends', async () => {
// We can't use the toErrorDev helper here because this is async.
const originalConsoleError = console.error;
@@ -4095,7 +4085,6 @@ describe('ReactDOMFizzServer', () => {
mockError(...args.map(normalizeCodeLocInfo));
};
let isClient = false;
let shouldThrow = true;
let promise = null;
let unsuspend = null;
let isResolved = false;
@@ -4116,36 +4105,37 @@ describe('ReactDOMFizzServer', () => {
return null;
}
function ThrowUntilOnClient({children, message}) {
if (isClient && shouldThrow) {
Scheduler.log('throwing: ' + message);
throw new Error(message);
}
function ThrowWhenHydrating({children, message}) {
// This is a trick to only throw if we're hydrating, because
// useSyncExternalStore calls getServerSnapshot instead of the regular
// getSnapshot in that case.
useSyncExternalStore(
() => {},
t => t,
() => {
if (isClient) {
Scheduler.log('throwing: ' + message);
throw new Error(message);
}
},
);
return children;
}
function StopThrowingOnClient() {
if (isClient) {
shouldThrow = false;
}
return null;
}
const App = () => {
return (
<div>
<Suspense fallback={<h1>Loading...</h1>}>
<ComponentThatSuspendsOnClient />
<ThrowUntilOnClient message="first error">
<ThrowWhenHydrating message="first error">
<h1>one</h1>
</ThrowUntilOnClient>
<ThrowUntilOnClient message="second error">
</ThrowWhenHydrating>
<ThrowWhenHydrating message="second error">
<h2>two</h2>
</ThrowUntilOnClient>
<ThrowUntilOnClient message="third error">
</ThrowWhenHydrating>
<ThrowWhenHydrating message="third error">
<h3>three</h3>
</ThrowUntilOnClient>
<StopThrowingOnClient />
</ThrowWhenHydrating>
</Suspense>
</div>
);
@@ -4172,15 +4162,7 @@ describe('ReactDOMFizzServer', () => {
Scheduler.log('Logged recoverable error: ' + error.message);
},
});
await waitForAll([
'suspending',
'throwing: first error',
// There is no repeated first error because we already suspended and no
// invokeGuardedCallback is used if we are in dev
// or in prod there is just never an invokeGuardedCallback
'throwing: second error',
'throwing: third error',
]);
await waitForAll(['suspending']);
expect(mockError.mock.calls).toEqual([]);
expect(getVisibleChildren(container)).toEqual(
@@ -4191,18 +4173,31 @@ describe('ReactDOMFizzServer', () => {
</div>,
);
await unsuspend();
// Since our client components only throw on the very first render there are no
// new throws in this pass
await waitForAll([]);
expect(mockError.mock.calls).toEqual([]);
await waitForAll([
'throwing: first error',
'throwing: first error',
'Logged recoverable error: first error',
'Logged recoverable error: There was an error while hydrating this Suspense boundary. Switched to client rendering.',
]);
expect(getVisibleChildren(container)).toEqual(
<div>
<h1>one</h1>
<h2>two</h2>
<h3>three</h3>
</div>,
);
} finally {
console.error = originalConsoleError;
}
});
// @gate __DEV__
it('suspending after erroring will cause errors previously queued to be silenced until the boundary resolves', async () => {
it('(outdated behavior) suspending after erroring will cause errors previously queued to be silenced until the boundary resolves', async () => {
// NOTE: This test was originally written to test a scenario that doesn't happen
// anymore. If something errors during hydration, we immediately unwind the
// stack and revert to client rendering. I've kept the test around just to
// demonstrate what actually happens in this sequence of events.
// We can't use the toErrorDev helper here because this is async.
const originalConsoleError = console.error;
const mockError = jest.fn();
@@ -4216,7 +4211,6 @@ describe('ReactDOMFizzServer', () => {
mockError(...args.map(normalizeCodeLocInfo));
};
let isClient = false;
let shouldThrow = true;
let promise = null;
let unsuspend = null;
let isResolved = false;
@@ -4237,36 +4231,37 @@ describe('ReactDOMFizzServer', () => {
return null;
}
function ThrowUntilOnClient({children, message}) {
if (isClient && shouldThrow) {
Scheduler.log('throwing: ' + message);
throw new Error(message);
}
function ThrowWhenHydrating({children, message}) {
// This is a trick to only throw if we're hydrating, because
// useSyncExternalStore calls getServerSnapshot instead of the regular
// getSnapshot in that case.
useSyncExternalStore(
() => {},
t => t,
() => {
if (isClient) {
Scheduler.log('throwing: ' + message);
throw new Error(message);
}
},
);
return children;
}
function StopThrowingOnClient() {
if (isClient) {
shouldThrow = false;
}
return null;
}
const App = () => {
return (
<div>
<Suspense fallback={<h1>Loading...</h1>}>
<ThrowUntilOnClient message="first error">
<ThrowWhenHydrating message="first error">
<h1>one</h1>
</ThrowUntilOnClient>
<ThrowUntilOnClient message="second error">
</ThrowWhenHydrating>
<ThrowWhenHydrating message="second error">
<h2>two</h2>
</ThrowUntilOnClient>
</ThrowWhenHydrating>
<ComponentThatSuspendsOnClient />
<ThrowUntilOnClient message="third error">
<ThrowWhenHydrating message="third error">
<h3>three</h3>
</ThrowUntilOnClient>
<StopThrowingOnClient />
</ThrowWhenHydrating>
</Suspense>
</div>
);
@@ -4297,9 +4292,9 @@ describe('ReactDOMFizzServer', () => {
'throwing: first error',
// duplicate because first error is re-done in invokeGuardedCallback
'throwing: first error',
'throwing: second error',
'suspending',
'throwing: third error',
'Logged recoverable error: first error',
'Logged recoverable error: There was an error while hydrating this Suspense boundary. Switched to client rendering.',
]);
// These Uncaught error calls are the error reported by the runtime (jsdom here, browser in actual use)
// when invokeGuardedCallback is used to replay an error in dev using event dispatching in the document
@@ -4312,9 +4307,7 @@ describe('ReactDOMFizzServer', () => {
expect(getVisibleChildren(container)).toEqual(
<div>
<h1>one</h1>
<h2>two</h2>
<h3>three</h3>
<h1>Loading...</h1>
</div>,
);
await unsuspend();
@@ -4322,6 +4315,14 @@ describe('ReactDOMFizzServer', () => {
// new throws in this pass
await waitForAll([]);
expect(mockError.mock.calls).toEqual([]);
expect(getVisibleChildren(container)).toEqual(
<div>
<h1>one</h1>
<h2>two</h2>
<h3>three</h3>
</div>,
);
} finally {
console.error = originalConsoleError;
}
@@ -479,7 +479,6 @@ describe('ReactDOMFizzServerHydrationWarning', () => {
});
await expect(async () => {
await waitForAll([
'Hydration failed because the initial UI does not match what was rendered on the server.',
'Hydration failed because the initial UI does not match what was rendered on the server.',
'There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.',
]);
@@ -488,17 +488,16 @@ describe('ReactDOMServerHydration', () => {
);
}
expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
[
"Warning: Expected server HTML to contain a matching <main> in <div>.
in main (at **)
in div (at **)
in Mismatch (at **)",
"Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.",
"Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
"Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
"Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
]
`);
[
"Warning: Expected server HTML to contain a matching <main> in <div>.
in main (at **)
in div (at **)
in Mismatch (at **)",
"Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.",
"Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
"Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
]
`);
});
// @gate __DEV__
@@ -579,17 +578,16 @@ describe('ReactDOMServerHydration', () => {
);
}
expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
[
"Warning: Expected server HTML to contain a matching <main> in <div>.
in main (at **)
in div (at **)
in Mismatch (at **)",
"Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.",
"Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
"Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
"Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
]
`);
[
"Warning: Expected server HTML to contain a matching <main> in <div>.
in main (at **)
in div (at **)
in Mismatch (at **)",
"Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.",
"Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
"Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
]
`);
});
// @gate __DEV__
@@ -872,18 +870,16 @@ describe('ReactDOMServerHydration', () => {
);
}
expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
[
"Warning: Expected server HTML to contain a matching <header> in <div>.
in header (at **)
in div (at **)
in Mismatch (at **)",
"Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.",
"Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
"Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
"Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
"Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
]
`);
[
"Warning: Expected server HTML to contain a matching <header> in <div>.
in header (at **)
in div (at **)
in Mismatch (at **)",
"Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.",
"Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
"Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
]
`);
});
// @gate __DEV__
@@ -312,13 +312,7 @@ describe('ReactDOMServerPartialHydration', () => {
Scheduler.log(error.message);
},
});
await waitForAll([
'Suspend',
'Component',
'Component',
'Component',
'Component',
]);
await waitForAll(['Suspend']);
jest.runAllTimers();
// Unchanged
@@ -1415,7 +1409,7 @@ describe('ReactDOMServerPartialHydration', () => {
);
// This will throw it away and rerender.
await waitForAll(['Child', 'Sibling']);
await waitForAll(['Child']);
expect(container.textContent).toBe('Hello');
@@ -1000,10 +1000,7 @@ describe('ReactErrorBoundaries', () => {
'BrokenRender constructor',
'BrokenRender componentWillMount',
'BrokenRender render [!]',
// Render third child, even though an earlier sibling threw.
'Normal constructor',
'Normal componentWillMount',
'Normal render',
// Skip the remaining siblings
// Handle the error
'ErrorBoundary static getDerivedStateFromError',
'ErrorBoundary componentWillMount',
@@ -1040,10 +1040,7 @@ describe('ReactLegacyErrorBoundaries', () => {
'BrokenRender constructor',
'BrokenRender componentWillMount',
'BrokenRender render [!]',
// Render third child, even though an earlier sibling threw.
'Normal constructor',
'Normal componentWillMount',
'Normal render',
// Skip the remaining siblings
// Finish mounting with null children
'ErrorBoundary componentDidMount',
// Handle the error
+150 -76
View File
@@ -39,6 +39,7 @@ import {
enableCache,
enableTransitionTracing,
useModernStrictMode,
revertRemovalOfSiblingPrerendering,
} from 'shared/ReactFeatureFlags';
import ReactSharedInternals from 'shared/ReactSharedInternals';
import is from 'shared/objectIs';
@@ -2173,10 +2174,10 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) {
break outer;
}
default: {
// Continue with the normal work loop.
// Unwind then continue with the normal work loop.
workInProgressSuspendedReason = NotSuspended;
workInProgressThrownValue = null;
unwindSuspendedUnitOfWork(unitOfWork, thrownValue);
throwAndUnwindWorkLoop(unitOfWork, thrownValue);
break;
}
}
@@ -2285,7 +2286,7 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
// Unwind then continue with the normal work loop.
workInProgressSuspendedReason = NotSuspended;
workInProgressThrownValue = null;
unwindSuspendedUnitOfWork(unitOfWork, thrownValue);
throwAndUnwindWorkLoop(unitOfWork, thrownValue);
break;
}
case SuspendedOnData: {
@@ -2343,7 +2344,7 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
// Otherwise, unwind then continue with the normal work loop.
workInProgressSuspendedReason = NotSuspended;
workInProgressThrownValue = null;
unwindSuspendedUnitOfWork(unitOfWork, thrownValue);
throwAndUnwindWorkLoop(unitOfWork, thrownValue);
}
break;
}
@@ -2400,7 +2401,7 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
// Otherwise, unwind then continue with the normal work loop.
workInProgressSuspendedReason = NotSuspended;
workInProgressThrownValue = null;
unwindSuspendedUnitOfWork(unitOfWork, thrownValue);
throwAndUnwindWorkLoop(unitOfWork, thrownValue);
break;
}
case SuspendedOnDeprecatedThrowPromise: {
@@ -2410,7 +2411,7 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
// always unwind.
workInProgressSuspendedReason = NotSuspended;
workInProgressThrownValue = null;
unwindSuspendedUnitOfWork(unitOfWork, thrownValue);
throwAndUnwindWorkLoop(unitOfWork, thrownValue);
break;
}
case SuspendedOnHydration: {
@@ -2610,7 +2611,7 @@ function replaySuspendedUnitOfWork(unitOfWork: Fiber): void {
ReactCurrentOwner.current = null;
}
function unwindSuspendedUnitOfWork(unitOfWork: Fiber, thrownValue: mixed) {
function throwAndUnwindWorkLoop(unitOfWork: Fiber, thrownValue: mixed) {
// This is a fork of performUnitOfWork specifcally for unwinding a fiber
// that threw an exception.
//
@@ -2655,8 +2656,21 @@ function unwindSuspendedUnitOfWork(unitOfWork: Fiber, thrownValue: mixed) {
throw error;
}
// Return to the normal work loop.
completeUnitOfWork(unitOfWork);
if (unitOfWork.flags & Incomplete) {
// Unwind the stack until we reach the nearest boundary.
unwindUnitOfWork(unitOfWork);
} else {
// Although the fiber suspended, we're intentionally going to commit it in
// an inconsistent state. We can do this safely in cases where we know the
// inconsistent tree will be hidden.
//
// This currently only applies to Legacy Suspense implementation, but we may
// port a version of this to concurrent roots, too, when performing a
// synchronous render. Because that will allow us to mutate the tree as we
// go instead of buffering mutations until the end. Though it's unclear if
// this particular path is how that would be implemented.
completeUnitOfWork(unitOfWork);
}
}
function completeUnitOfWork(unitOfWork: Fiber): void {
@@ -2664,81 +2678,53 @@ function completeUnitOfWork(unitOfWork: Fiber): void {
// sibling. If there are no more siblings, return to the parent fiber.
let completedWork: Fiber = unitOfWork;
do {
if (revertRemovalOfSiblingPrerendering) {
if ((completedWork.flags & Incomplete) !== NoFlags) {
// This fiber did not complete, because one of its children did not
// complete. Switch to unwinding the stack instead of completing it.
//
// The reason "unwind" and "complete" is interleaved is because when
// something suspends, we continue rendering the siblings even though
// they will be replaced by a fallback.
// TODO: Disable sibling prerendering, then remove this branch.
unwindUnitOfWork(completedWork);
return;
}
} else {
if (__DEV__) {
if ((completedWork.flags & Incomplete) !== NoFlags) {
// NOTE: If we re-enable sibling prerendering in some cases, this branch
// is where we would switch to the unwinding path.
console.error(
'Internal React error: Expected this fiber to be complete, but ' +
"it isn't. It should have been unwound. This is a bug in React.",
);
}
}
}
// The current, flushed, state of this fiber is the alternate. Ideally
// nothing should rely on this, but relying on it here means that we don't
// need an additional field on the work in progress.
const current = completedWork.alternate;
const returnFiber = completedWork.return;
// Check if the work completed or if something threw.
if ((completedWork.flags & Incomplete) === NoFlags) {
setCurrentDebugFiberInDEV(completedWork);
let next;
if (
!enableProfilerTimer ||
(completedWork.mode & ProfileMode) === NoMode
) {
next = completeWork(current, completedWork, renderLanes);
} else {
startProfilerTimer(completedWork);
next = completeWork(current, completedWork, renderLanes);
// Update render duration assuming we didn't error.
stopProfilerTimerIfRunningAndRecordDelta(completedWork, false);
}
resetCurrentDebugFiberInDEV();
if (next !== null) {
// Completing this fiber spawned new work. Work on that next.
workInProgress = next;
return;
}
setCurrentDebugFiberInDEV(completedWork);
let next;
if (!enableProfilerTimer || (completedWork.mode & ProfileMode) === NoMode) {
next = completeWork(current, completedWork, renderLanes);
} else {
// This fiber did not complete because something threw. Pop values off
// the stack without entering the complete phase. If this is a boundary,
// capture values if possible.
const next = unwindWork(current, completedWork, renderLanes);
startProfilerTimer(completedWork);
next = completeWork(current, completedWork, renderLanes);
// Update render duration assuming we didn't error.
stopProfilerTimerIfRunningAndRecordDelta(completedWork, false);
}
resetCurrentDebugFiberInDEV();
// Because this fiber did not complete, don't reset its lanes.
if (next !== null) {
// If completing this work spawned new work, do that next. We'll come
// back here again.
// Since we're restarting, remove anything that is not a host effect
// from the effect tag.
next.flags &= HostEffectMask;
workInProgress = next;
return;
}
if (
enableProfilerTimer &&
(completedWork.mode & ProfileMode) !== NoMode
) {
// Record the render duration for the fiber that errored.
stopProfilerTimerIfRunningAndRecordDelta(completedWork, false);
// Include the time spent working on failed children before continuing.
let actualDuration = completedWork.actualDuration;
let child = completedWork.child;
while (child !== null) {
// $FlowFixMe[unsafe-addition] addition with possible null/undefined value
actualDuration += child.actualDuration;
child = child.sibling;
}
completedWork.actualDuration = actualDuration;
}
if (returnFiber !== null) {
// Mark the parent fiber as incomplete and clear its subtree flags.
returnFiber.flags |= Incomplete;
returnFiber.subtreeFlags = NoFlags;
returnFiber.deletions = null;
} else {
// We've unwound all the way to the root.
workInProgressRootExitStatus = RootDidNotComplete;
workInProgress = null;
return;
}
if (next !== null) {
// Completing this fiber spawned new work. Work on that next.
workInProgress = next;
return;
}
const siblingFiber = completedWork.sibling;
@@ -2760,6 +2746,94 @@ function completeUnitOfWork(unitOfWork: Fiber): void {
}
}
function unwindUnitOfWork(unitOfWork: Fiber): void {
let incompleteWork: Fiber = unitOfWork;
do {
// The current, flushed, state of this fiber is the alternate. Ideally
// nothing should rely on this, but relying on it here means that we don't
// need an additional field on the work in progress.
const current = incompleteWork.alternate;
// This fiber did not complete because something threw. Pop values off
// the stack without entering the complete phase. If this is a boundary,
// capture values if possible.
const next = unwindWork(current, incompleteWork, renderLanes);
// Because this fiber did not complete, don't reset its lanes.
if (next !== null) {
// Found a boundary that can handle this exception. Re-renter the
// begin phase. This branch will return us to the normal work loop.
//
// Since we're restarting, remove anything that is not a host effect
// from the effect tag.
next.flags &= HostEffectMask;
workInProgress = next;
return;
}
// Keep unwinding until we reach either a boundary or the root.
if (enableProfilerTimer && (incompleteWork.mode & ProfileMode) !== NoMode) {
// Record the render duration for the fiber that errored.
stopProfilerTimerIfRunningAndRecordDelta(incompleteWork, false);
// Include the time spent working on failed children before continuing.
let actualDuration = incompleteWork.actualDuration;
let child = incompleteWork.child;
while (child !== null) {
// $FlowFixMe[unsafe-addition] addition with possible null/undefined value
actualDuration += child.actualDuration;
child = child.sibling;
}
incompleteWork.actualDuration = actualDuration;
}
// TODO: Once we stop prerendering siblings, instead of resetting the parent
// of the node being unwound, we should be able to reset node itself as we
// unwind the stack. Saves an additional null check.
const returnFiber = incompleteWork.return;
if (returnFiber !== null) {
// Mark the parent fiber as incomplete and clear its subtree flags.
// TODO: Once we stop prerendering siblings, we may be able to get rid of
// the Incomplete flag because unwinding to the nearest boundary will
// happen synchronously.
returnFiber.flags |= Incomplete;
returnFiber.subtreeFlags = NoFlags;
returnFiber.deletions = null;
}
if (revertRemovalOfSiblingPrerendering) {
// If there are siblings, work on them now even though they're going to be
// replaced by a fallback. We're "prerendering" them. Historically our
// rationale for this behavior has been to initiate any lazy data requests
// in the siblings, and also to warm up the CPU cache.
// TODO: Don't prerender siblings. With `use`, we suspend the work loop
// until the data has resolved, anyway.
const siblingFiber = incompleteWork.sibling;
if (siblingFiber !== null) {
// This branch will return us to the normal work loop.
workInProgress = siblingFiber;
return;
}
} else {
// NOTE: If we re-enable sibling prerendering in some cases, this branch
// is where we would switch to the normal completion path: check if a
// sibling exists, and if so, begin work on it.
}
// Otherwise, return to the parent
// $FlowFixMe[incompatible-type] we bail out when we get a null
incompleteWork = returnFiber;
// Update the next thing we're working on in case something throws.
workInProgress = incompleteWork;
} while (incompleteWork !== null);
// We've unwound all the way to the root.
workInProgressRootExitStatus = RootDidNotComplete;
workInProgress = null;
}
function commitRoot(
root: FiberRoot,
recoverableErrors: null | Array<CapturedValue<mixed>>,
@@ -111,7 +111,7 @@ describe('ReactBlockingMode', () => {
</Suspense>,
);
await waitForAll(['A', 'Suspend! [B]', 'C', 'Loading...']);
await waitForAll(['A', 'Suspend! [B]', 'Loading...']);
// In Legacy Mode, A and B would mount in a hidden primary tree. In
// Concurrent Mode, nothing in the primary tree should mount. But the
// fallback should mount immediately.
+1 -1
View File
@@ -1057,7 +1057,7 @@ describe('ReactCache', () => {
await act(() => {
root.render(<App showMore={false} />);
});
assertLog(['Cache miss! [A]', 'Cache miss! [B]', 'Loading...']);
assertLog(['Cache miss! [A]', 'Loading...']);
expect(root).toMatchRenderedOutput('Loading...');
await act(() => {
@@ -450,20 +450,16 @@ describe('ReactConcurrentErrorRecovery', () => {
await act(() => {
startTransition(() => {
root.render(
<ErrorBoundary>
<>
<AsyncText text="Async" />
<Throws />
</ErrorBoundary>,
<ErrorBoundary>
<Throws />
</ErrorBoundary>
</>,
);
});
});
assertLog([
'Suspend! [Async]',
// TODO: Ideally we would skip this second render pass to render the
// error UI, since it's not going to commit anyway. The same goes for
// Suspense fallbacks during a refresh transition.
'Caught an error: Oops!',
]);
assertLog(['Suspend! [Async]']);
// The render suspended without committing or surfacing the error.
expect(root).toMatchRenderedOutput(null);
@@ -471,14 +467,16 @@ describe('ReactConcurrentErrorRecovery', () => {
await act(() => {
startTransition(() => {
root.render(
<ErrorBoundary>
<Throws />
<>
<AsyncText text="Async" />
</ErrorBoundary>,
<ErrorBoundary>
<Throws />
</ErrorBoundary>
</>,
);
});
});
assertLog(['Suspend! [Async]', 'Caught an error: Oops!']);
assertLog(['Suspend! [Async]']);
expect(root).toMatchRenderedOutput(null);
await act(async () => {
@@ -494,7 +492,7 @@ describe('ReactConcurrentErrorRecovery', () => {
'Caught an error: Oops!',
]);
expect(root).toMatchRenderedOutput('Caught an error: Oops!');
expect(root).toMatchRenderedOutput('AsyncCaught an error: Oops!');
},
);
});
@@ -72,7 +72,7 @@ describe('ReactSuspenseList', () => {
React.startTransition(() => {
root.render(<App show={true} />);
});
await waitForAll(['Suspend! [A]', 'Suspend! [B]', 'Loading...']);
await waitForAll(['Suspend! [A]', 'Loading...']);
expect(root).toMatchRenderedOutput(null);
Scheduler.unstable_advanceTime(2000);
@@ -647,7 +647,7 @@ describe('ReactExpiration', () => {
React.startTransition(() => {
root.render(<App step={1} />);
});
await waitForAll(['Suspend! [A1]', 'B', 'C', 'Loading...']);
await waitForAll(['Suspend! [A1]', 'Loading...']);
// Lots of time elapses before the promise resolves
Scheduler.unstable_advanceTime(10000);
@@ -99,17 +99,19 @@ describe('ReactIncrementalErrorHandling', () => {
React.startTransition(() => {
ReactNoop.render(
<ErrorBoundary>
<Indirection>
<>
<ErrorBoundary>
<Indirection>
<Indirection>
<BadRender />
<Indirection />
<Indirection />
<Indirection>
<BadRender />
</Indirection>
</Indirection>
</Indirection>
</Indirection>
</ErrorBoundary>,
</ErrorBoundary>
<Indirection />
<Indirection />
</>,
);
});
@@ -121,13 +123,6 @@ describe('ReactIncrementalErrorHandling', () => {
'Indirection',
// An error is thrown. React keeps rendering asynchronously.
'throw',
]);
// Still rendering async...
await waitFor(['Indirection']);
await waitFor([
'Indirection',
// Call getDerivedStateFromError and re-render the error boundary, this
// time rendering an error message.
@@ -135,14 +130,20 @@ describe('ReactIncrementalErrorHandling', () => {
'ErrorBoundary (catch)',
'ErrorMessage',
]);
// Since the error was thrown during an async render, React won't commit
// the result yet.
expect(ReactNoop).toMatchRenderedOutput(null);
// Instead, it will try rendering one more time, synchronously, in case that
// happens to fix the error.
// The work loop unwound to the nearest error boundary. Continue rendering
// asynchronously.
await waitFor(['Indirection']);
// Since the error was thrown during an async render, React won't commit the
// result yet. After render we render the last child, React will attempt to
// render again, synchronously, just in case that happens to fix the error
// (i.e. as in the case of a data race). Flush just one more unit of work to
// demonstrate that this render is synchronous.
expect(ReactNoop.flushNextYield()).toEqual([
'Indirection',
'ErrorBoundary (try)',
'Indirection',
'Indirection',
@@ -151,11 +152,11 @@ describe('ReactIncrementalErrorHandling', () => {
// The error was thrown again. This time, React will actually commit
// the result.
'throw',
'Indirection',
'Indirection',
'getDerivedStateFromError',
'ErrorBoundary (catch)',
'ErrorMessage',
'Indirection',
'Indirection',
]);
expect(ReactNoop).toMatchRenderedOutput(
@@ -197,17 +198,19 @@ describe('ReactIncrementalErrorHandling', () => {
React.startTransition(() => {
ReactNoop.render(
<ErrorBoundary>
<Indirection>
<>
<ErrorBoundary>
<Indirection>
<Indirection>
<BadRender />
<Indirection />
<Indirection />
<Indirection>
<BadRender />
</Indirection>
</Indirection>
</Indirection>
</Indirection>
</ErrorBoundary>,
</ErrorBoundary>
<Indirection />
<Indirection />
</>,
);
});
@@ -384,12 +387,11 @@ describe('ReactIncrementalErrorHandling', () => {
// Render the bad component asynchronously
await waitFor(['Parent', 'BadRender']);
// Finish the rest of the async work
await waitFor(['Sibling']);
// Old scheduler renders, commits, and throws synchronously
// The work loop unwound to the nearest error boundary. React will try
// to render one more time, synchronously. Flush just one unit of work to
// demonstrate that this render is synchronous.
expect(() => Scheduler.unstable_flushNumberOfYields(1)).toThrow('oops');
assertLog(['Parent', 'BadRender', 'Sibling', 'commit']);
assertLog(['Parent', 'BadRender', 'commit']);
expect(ReactNoop).toMatchRenderedOutput(null);
});
@@ -435,16 +437,12 @@ describe('ReactIncrementalErrorHandling', () => {
// The render expired, but we shouldn't throw out the partial work.
// Finish the current level.
'Oops',
'C',
'D',
// Since the error occurred during a partially concurrent render, we should
// retry one more time, synchronously.
'A',
'B',
'Oops',
'C',
'D',
]);
expect(ReactNoop).toMatchRenderedOutput(null);
});
@@ -1571,14 +1569,10 @@ describe('ReactIncrementalErrorHandling', () => {
'ErrorBoundary (try)',
'throw',
// Continue rendering siblings after BadRender throws
'BadRenderSibling',
'BadRenderSibling',
// React retries one more time
'ErrorBoundary (try)',
'throw',
'BadRenderSibling',
'BadRenderSibling',
// Errored again on retry. Now handle it.
'componentDidCatch',
@@ -5,6 +5,7 @@ let Scheduler;
let ReactFeatureFlags;
let Suspense;
let lazy;
let waitFor;
let waitForAll;
let waitForThrow;
let assertLog;
@@ -34,6 +35,7 @@ describe('ReactLazy', () => {
Scheduler = require('scheduler');
const InternalTestUtils = require('internal-test-utils');
waitFor = InternalTestUtils.waitFor;
waitForAll = InternalTestUtils.waitForAll;
waitForThrow = InternalTestUtils.waitForThrow;
assertLog = InternalTestUtils.assertLog;
@@ -256,8 +258,14 @@ describe('ReactLazy', () => {
}
}
const LazyChildA = lazy(() => fakeImport(Child));
const LazyChildB = lazy(() => fakeImport(Child));
const LazyChildA = lazy(() => {
Scheduler.log('Suspend! [LazyChildA]');
return fakeImport(Child);
});
const LazyChildB = lazy(() => {
Scheduler.log('Suspend! [LazyChildB]');
return fakeImport(Child);
});
function Parent({swap}) {
return (
@@ -279,11 +287,16 @@ describe('ReactLazy', () => {
unstable_isConcurrent: true,
});
await waitForAll(['Loading...']);
await waitForAll(['Suspend! [LazyChildA]', 'Loading...']);
expect(root).not.toMatchRenderedOutput('AB');
await resolveFakeImport(Child);
// B suspends even though it happens to share the same import as A.
// TODO: React.lazy should implement the `status` and `value` fields, so
// we can unwrap the result synchronously if it already loaded. Like `use`.
await waitFor(['A', 'Suspend! [LazyChildB]']);
await waitForAll(['A', 'B', 'Did mount: A', 'Did mount: B']);
expect(root).toMatchRenderedOutput('AB');
@@ -1389,12 +1402,13 @@ describe('ReactLazy', () => {
unstable_isConcurrent: true,
});
await waitForAll(['Init A', 'Init B', 'Loading...']);
await waitForAll(['Init A', 'Loading...']);
expect(root).not.toMatchRenderedOutput('AB');
await resolveFakeImport(ChildA);
await resolveFakeImport(ChildB);
await waitForAll(['A', 'Init B']);
await resolveFakeImport(ChildB);
await waitForAll(['A', 'B', 'Did mount: A', 'Did mount: B']);
expect(root).toMatchRenderedOutput('AB');
@@ -134,8 +134,6 @@ describe('ReactSuspense', () => {
'Bar',
// A suspends
'Suspend! [A]',
// But we keep rendering the siblings
'B',
'Loading...',
]);
expect(root).toMatchRenderedOutput(null);
@@ -272,13 +270,7 @@ describe('ReactSuspense', () => {
unstable_isConcurrent: true,
});
await waitForAll([
'Foo',
'Suspend! [A]',
'Suspend! [B]',
'Loading more...',
'Loading...',
]);
await waitForAll(['Foo', 'Suspend! [A]', 'Loading...']);
expect(root).toMatchRenderedOutput('Loading...');
await resolveText('A');
@@ -316,13 +308,7 @@ describe('ReactSuspense', () => {
unstable_isConcurrent: true,
});
await waitForAll([
'Foo',
'Suspend! [A]',
'Suspend! [B]',
'Loading more...',
'Loading...',
]);
await waitForAll(['Foo', 'Suspend! [A]', 'Loading...']);
expect(root).toMatchRenderedOutput('Loading...');
await resolveText('A');
@@ -937,11 +923,7 @@ describe('ReactSuspense', () => {
unstable_isConcurrent: true,
});
await waitForAll([
'Suspend! [Child 1]',
'Suspend! [Child 2]',
'Loading...',
]);
await waitForAll(['Suspend! [Child 1]', 'Loading...']);
await resolveText('Child 1');
await waitForAll(['Child 1', 'Suspend! [Child 2]']);
@@ -126,7 +126,7 @@ describe('ReactSuspense', () => {
ReactNoop.render(element);
await waitForAll([]);
expect(ReactNoop).toMatchRenderedOutput('Waiting Tier 1');
expect(ops).toEqual([new Set([promise1, promise2])]);
expect(ops).toEqual([new Set([promise1])]);
ops = [];
await resolve1();
@@ -266,7 +266,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
'App render',
'Text:Inside:Before render',
'Suspend:Async',
'ClassText:Inside:After render',
'Text:Fallback render',
'Text:Outside render',
'Text:Fallback create layout',
@@ -641,7 +640,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
'App render',
'Text:Inside:Before render',
'Suspend:Async',
'Text:Inside:After render',
'Text:Fallback render',
'Text:Outside render',
]);
@@ -796,7 +794,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
'App render',
'ClassText:Inside:Before render',
'Suspend:Async',
'ClassText:Inside:After render',
'ClassText:Fallback render',
'ClassText:Outside render',
]);
@@ -917,13 +914,7 @@ describe('ReactSuspenseEffectsSemantics', () => {
<AsyncText text="Async" ms={1000} />
</App>,
);
await waitFor([
'App render',
'Suspend:Async',
'Text:Outer render',
'Text:Inner render',
'Text:Fallback render',
]);
await waitFor(['App render', 'Suspend:Async', 'Text:Fallback render']);
expect(ReactNoop).toMatchRenderedOutput(
<span prop="Outer">
<span prop="Inner" />
@@ -1047,7 +1038,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
await waitFor([
'App render',
'Suspend:Async',
'Text:Outer render',
// Text:MemoizedInner is memoized
'Text:Fallback render',
]);
@@ -1186,9 +1176,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
assertLog([
'Text:Outer render',
'Suspend:OuterAsync_1',
'Text:Inner render',
'Suspend:InnerAsync_1',
'Text:InnerFallback render',
'Text:OuterFallback render',
'Text:Outer destroy layout',
'Text:InnerFallback destroy layout',
@@ -1208,12 +1195,7 @@ describe('ReactSuspenseEffectsSemantics', () => {
await act(async () => {
await resolveText('InnerAsync_1');
});
assertLog([
'Text:Outer render',
'Suspend:OuterAsync_1',
'Text:Inner render',
'AsyncText:InnerAsync_1 render',
]);
assertLog(['Text:Outer render', 'Suspend:OuterAsync_1']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Outer" hidden={true} />
@@ -1236,9 +1218,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
assertLog([
'Text:Outer render',
'Suspend:OuterAsync_1',
'Text:Inner render',
'Suspend:InnerAsync_2',
'Text:InnerFallback render',
'Text:OuterFallback render',
]);
expect(ReactNoop).toMatchRenderedOutput(
@@ -1310,8 +1289,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
assertLog([
'Text:Outer render',
'Suspend:OuterAsync_2',
'Text:Inner render',
'AsyncText:InnerAsync_2 render',
'Text:OuterFallback render',
'Text:Outer destroy layout',
'AsyncText:OuterAsync_1 destroy layout',
@@ -1426,9 +1403,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
assertLog([
'Text:Outer render',
'Suspend:OuterAsync_1',
'Text:Inner render',
'Suspend:InnerAsync_1',
'Text:InnerFallback render',
'Text:OuterFallback render',
'Text:Outer destroy layout',
'Text:InnerFallback destroy layout',
@@ -1922,8 +1896,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
'ErrorBoundary render: try',
'App render',
'Suspend:Async',
'ThrowsInDidMount render',
'Text:Inside render',
'Text:Fallback render',
'Text:Outside render',
'ThrowsInDidMount componentWillUnmount',
@@ -2058,8 +2030,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
'ErrorBoundary render: try',
'App render',
'Suspend:Async',
'ThrowsInWillUnmount render',
'Text:Inside render',
'Text:Fallback render',
'Text:Outside render',
@@ -2170,8 +2140,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
'ErrorBoundary render: try',
'App render',
'Suspend:Async',
'ThrowsInLayoutEffect render',
'Text:Inside render',
'Text:Fallback render',
'Text:Outside render',
'ThrowsInLayoutEffect useLayoutEffect destroy',
@@ -2307,8 +2275,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
'ErrorBoundary render: try',
'App render',
'Suspend:Async',
'ThrowsInLayoutEffectDestroy render',
'Text:Inside render',
'Text:Fallback render',
'Text:Outside render',
@@ -2399,8 +2365,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
await waitFor([
'Text:Function render',
'Suspend:Async_1',
'Suspend:Async_2',
'ClassText:Class render',
'ClassText:Fallback render',
]);
expect(ReactNoop).toMatchRenderedOutput(
@@ -2435,7 +2399,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
'Text:Function render',
'AsyncText:Async_1 render',
'Suspend:Async_2',
'ClassText:Class render',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
@@ -2554,7 +2517,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
'Text:Function render',
'Suspender "A" render',
'Suspend:A',
'ClassText:Class render',
'ClassText:Fallback render',
]);
expect(ReactNoop).toMatchRenderedOutput(
@@ -2588,12 +2550,7 @@ describe('ReactSuspenseEffectsSemantics', () => {
await act(async () => {
await resolveText('A');
});
assertLog([
'Text:Function render',
'Suspender "B" render',
'Suspend:B',
'ClassText:Class render',
]);
assertLog(['Text:Function render', 'Suspender "B" render', 'Suspend:B']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Function" hidden={true} />
@@ -2819,9 +2776,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
assertLog([
'App render',
'Suspend:Async',
'RefCheckerOuter render',
'RefCheckerInner:refObject render',
'RefCheckerInner:refCallback render',
'Text:Fallback render',
'RefCheckerOuter destroy layout refObject? true refCallback? true',
'RefCheckerInner:refObject destroy layout ref? false',
@@ -2925,11 +2879,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
assertLog([
'App render',
'Suspend:Async',
'RefCheckerOuter render',
'ClassComponent:refObject render',
'RefCheckerInner:refObject render',
'ClassComponent:refCallback render',
'RefCheckerInner:refCallback render',
'Text:Fallback render',
'RefCheckerOuter destroy layout refObject? true refCallback? true',
'RefCheckerInner:refObject destroy layout ref? false',
@@ -3029,11 +2978,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
assertLog([
'App render',
'Suspend:Async',
'RefCheckerOuter render',
'FunctionComponent render',
'RefCheckerInner:refObject render',
'FunctionComponent render',
'RefCheckerInner:refCallback render',
'Text:Fallback render',
'RefCheckerOuter destroy layout refObject? true refCallback? true',
'RefCheckerInner:refObject destroy layout ref? false',
@@ -3138,7 +3082,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
assertLog([
'App render',
'Suspend:Async',
'RefChecker render',
'Text:Fallback render',
'RefChecker destroy layout ref? true',
'Text:Fallback create layout',
@@ -3252,8 +3195,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
'ErrorBoundary render: try',
'App render',
'Suspend:Async',
'ThrowsInRefCallback render',
'Text:Inside render',
'Text:Fallback render',
'Text:Outside render',
'ThrowsInRefCallback refCallback ref? false',
@@ -134,7 +134,7 @@ describe('ReactSuspensePlaceholder', () => {
// Initial mount
ReactNoop.render(<App middleText="B" />);
await waitForAll(['A', 'Suspend! [B]', 'C', 'Loading...']);
await waitForAll(['A', 'Suspend! [B]', 'Loading...']);
expect(ReactNoop).toMatchRenderedOutput('Loading...');
jest.advanceTimersByTime(1000);
@@ -152,7 +152,7 @@ describe('ReactSuspensePlaceholder', () => {
// Update
ReactNoop.render(<App middleText="B2" />);
await waitForAll(['Suspend! [B2]', 'C', 'Loading...']);
await waitForAll(['Suspend! [B2]', 'Loading...']);
// Time out the update
jest.advanceTimersByTime(750);
@@ -196,7 +196,7 @@ describe('ReactSuspensePlaceholder', () => {
// Initial mount
ReactNoop.render(<App middleText="B" />);
await waitForAll(['A', 'Suspend! [B]', 'C', 'Loading...']);
await waitForAll(['A', 'Suspend! [B]', 'Loading...']);
expect(ReactNoop).not.toMatchRenderedOutput('ABC');
@@ -207,7 +207,7 @@ describe('ReactSuspensePlaceholder', () => {
// Update
ReactNoop.render(<App middleText="B2" />);
await waitForAll(['A', 'Suspend! [B2]', 'C', 'Loading...']);
await waitForAll(['A', 'Suspend! [B2]', 'Loading...']);
// Time out the update
jest.advanceTimersByTime(750);
await waitForAll([]);
@@ -241,7 +241,7 @@ describe('ReactSuspensePlaceholder', () => {
// Initial mount
ReactNoop.render(<App middleText="b" />);
await waitForAll(['a', 'Suspend! [b]', 'c', 'Loading...']);
await waitForAll(['a', 'Suspend! [b]', 'Loading...']);
expect(ReactNoop).toMatchRenderedOutput(<uppercase>LOADING...</uppercase>);
@@ -252,7 +252,7 @@ describe('ReactSuspensePlaceholder', () => {
// Update
ReactNoop.render(<App middleText="b2" />);
await waitForAll(['a', 'Suspend! [b2]', 'c', 'Loading...']);
await waitForAll(['a', 'Suspend! [b2]', 'Loading...']);
// Time out the update
jest.advanceTimersByTime(750);
await waitForAll([]);
@@ -344,7 +344,6 @@ describe('ReactSuspensePlaceholder', () => {
'App',
'Suspending',
'Suspend! [Loaded]',
'Text',
'Fallback',
]);
// Since this is initial render we immediately commit the fallback. Another test below
@@ -354,8 +353,8 @@ describe('ReactSuspensePlaceholder', () => {
// Initial mount only shows the "Loading..." Fallback.
// The treeBaseDuration then should be 10ms spent rendering Fallback,
// but the actualDuration should also include the 8ms spent rendering the hidden tree.
expect(onRender.mock.calls[0][2]).toBe(18);
// but the actualDuration should also include the 3ms spent rendering the hidden tree.
expect(onRender.mock.calls[0][2]).toBe(13);
expect(onRender.mock.calls[0][3]).toBe(10);
// Resolve the pending promise.
@@ -437,6 +436,30 @@ describe('ReactSuspensePlaceholder', () => {
});
it('properly accounts for base durations when a suspended times out in a concurrent tree', async () => {
const Fallback = () => {
Scheduler.log('Fallback');
Scheduler.unstable_advanceTime(10);
return 'Loading...';
};
const Suspending = () => {
Scheduler.log('Suspending');
Scheduler.unstable_advanceTime(2);
return <AsyncText ms={1000} text="Loaded" fakeRenderDuration={1} />;
};
App = ({shouldSuspend, text = 'Text', textRenderDuration = 5}) => {
Scheduler.log('App');
return (
<Profiler id="root" onRender={onRender}>
<Suspense fallback={<Fallback />}>
{shouldSuspend && <Suspending />}
<Text fakeRenderDuration={textRenderDuration} text={text} />
</Suspense>
</Profiler>
);
};
ReactNoop.render(
<>
<App shouldSuspend={false} textRenderDuration={5} />
@@ -463,7 +486,6 @@ describe('ReactSuspensePlaceholder', () => {
'App',
'Suspending',
'Suspend! [Loaded]',
'Text',
'Fallback',
]);
expect(ReactNoop).toMatchRenderedOutput('Text');
@@ -475,9 +497,9 @@ describe('ReactSuspensePlaceholder', () => {
// The suspense update should only show the "Loading..." Fallback.
// The actual duration should include 10ms spent rendering Fallback,
// plus the 8ms render all of the hidden, suspended subtree.
// plus the 3ms render all of the partially rendered suspended subtree.
// But the tree base duration should only include 10ms spent rendering Fallback.
expect(onRender.mock.calls[1][2]).toBe(18);
expect(onRender.mock.calls[1][2]).toBe(13);
expect(onRender.mock.calls[1][3]).toBe(10);
// Update again while timed out.
@@ -497,13 +519,12 @@ describe('ReactSuspensePlaceholder', () => {
// consequence of AsyncText relying on the same timer queue as React's
// internal Suspense timer. We should decouple our AsyncText helpers
// from timers.
Scheduler.unstable_advanceTime(100);
Scheduler.unstable_advanceTime(200);
await waitForAll([
'App',
'Suspending',
'Suspend! [Loaded]',
'New',
'Fallback',
'Suspend! [Sibling]',
]);
@@ -224,11 +224,11 @@ describe('ReactSuspenseWithNoopRenderer', () => {
'Bar',
// A suspends
'Suspend! [A]',
// But we keep rendering the siblings
'B',
// We immediately unwind and switch to a fallback without
// rendering siblings.
'Loading...',
'C',
// We leave D incomplete.
// Yield before rendering D
]);
expect(ReactNoop).toMatchRenderedOutput(null);
@@ -293,8 +293,8 @@ describe('ReactSuspenseWithNoopRenderer', () => {
'Bar',
// A suspends
'Suspend! [A]',
// But we keep rendering the siblings
'B',
// We immediately unwind and switch to a fallback without
// rendering siblings.
'Loading...',
]);
expect(ReactNoop).toMatchRenderedOutput(null);
@@ -367,7 +367,6 @@ describe('ReactSuspenseWithNoopRenderer', () => {
// A shell is needed. The update cause it to suspend.
ReactNoop.render(<Suspense fallback={<Text text="Loading..." />} />);
await waitForAll([]);
// B suspends. Continue rendering the remaining siblings.
React.startTransition(() => {
ReactNoop.render(
<Suspense fallback={<Text text="Loading..." />}>
@@ -378,8 +377,8 @@ describe('ReactSuspenseWithNoopRenderer', () => {
</Suspense>,
);
});
// B suspends. Continue rendering the remaining siblings.
await waitForAll(['A', 'Suspend! [B]', 'C', 'D', 'Loading...']);
// B suspends. Render a fallback
await waitForAll(['A', 'Suspend! [B]', 'Loading...']);
// Did not commit yet.
expect(ReactNoop).toMatchRenderedOutput(null);
@@ -594,7 +593,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
React.startTransition(() => {
ReactNoop.render(<App showA={true} showB={true} />);
});
await waitForAll(['Suspend! [A]', 'B', 'Loading...']);
await waitForAll(['Suspend! [A]', 'Loading...']);
expect(ReactNoop).toMatchRenderedOutput(null);
await resolveText('A');
@@ -778,8 +777,6 @@ describe('ReactSuspenseWithNoopRenderer', () => {
'Sync',
// The async content suspends
'Suspend! [Outer content]',
'Suspend! [Inner content]',
'Loading inner...',
'Loading outer...',
]);
// The outer loading state finishes immediately.
@@ -888,7 +885,6 @@ describe('ReactSuspenseWithNoopRenderer', () => {
assertLog([
'Suspend! [Async]',
'Suspend! [Loading (inner)...]',
'Sync',
'Loading (outer)...',
]);
// The tree commits synchronously
@@ -1018,7 +1014,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
</>,
);
});
await waitFor(['Suspend! [Async]', 'Sibling']);
await waitFor(['Suspend! [Async]']);
await resolveText('Async');
@@ -1059,7 +1055,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
<AsyncText text="B" />
</Suspense>,
);
await waitForAll(['Suspend! [A]', 'Suspend! [B]', 'Loading...']);
await waitForAll(['Suspend! [A]', 'Loading...']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
await resolveText('A');
@@ -1211,7 +1207,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
}
ReactNoop.render(<App />);
await waitForAll(['Suspend! [A]', 'Suspend! [B]', 'Suspend! [C]']);
await waitForAll(['Suspend! [A]']);
expect(ReactNoop).toMatchRenderedOutput('Loading...');
await resolveText('A');
@@ -1928,9 +1924,6 @@ describe('ReactSuspenseWithNoopRenderer', () => {
'Foo',
// A suspends
'Suspend! [A]',
// B suspends
'Suspend! [B]',
'Loading more...',
'Loading...',
]);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
@@ -1943,7 +1936,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
// Retry with the new content.
await waitForAll([
'A',
// B still suspends
// B suspends
'Suspend! [B]',
'Loading more...',
]);
@@ -1989,9 +1982,6 @@ describe('ReactSuspenseWithNoopRenderer', () => {
'Foo',
// A suspends
'Suspend! [A]',
// B suspends
'Suspend! [B]',
'Loading more...',
'Loading...',
]);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
@@ -2001,7 +1991,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
// Retry with the new content.
await waitForAll([
'A',
// B still suspends
// B suspends
'Suspend! [B]',
'Loading more...',
]);
@@ -2148,7 +2138,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
ReactNoop.flushSync(() => showB());
});
assertLog(['Suspend! [A]', 'Suspend! [B]']);
assertLog(['Suspend! [A]']);
});
// TODO: flip to "warns" when this is implemented again.
@@ -2254,7 +2244,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
}
ReactNoop.render(<Foo />);
await waitForAll(['Foo', 'Suspend! [A]', 'B', 'Initial load...']);
await waitForAll(['Foo', 'Suspend! [A]', 'Initial load...']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Initial load..." />);
// Eventually we resolve and show the data.
@@ -3638,7 +3628,6 @@ describe('ReactSuspenseWithNoopRenderer', () => {
'Outer text: B',
'Outer step: 0',
'Suspend! [Inner text: B]',
'Inner step: 0',
'Loading...',
]);
// Commit the placeholder
@@ -3667,7 +3656,6 @@ describe('ReactSuspenseWithNoopRenderer', () => {
'Outer text: B',
'Outer step: 1',
'Suspend! [Inner text: B]',
'Inner step: 1',
'Loading...',
]);
expect(root).toMatchRenderedOutput(
@@ -860,7 +860,6 @@ describe('ReactTransition', () => {
assertLog([
// Suspend.
'Suspend! [Async]',
'Normal pri: 0',
'Loading...',
]);
expect(root).toMatchRenderedOutput('(empty), Normal pri: 0');
@@ -752,10 +752,6 @@ describe('ReactInteractionTracing', () => {
await waitForAll([
'Suspend [Page Two]',
'Suspend [Show Text One]',
'Show Text One Loading...',
'Suspend [Show Text Two]',
'Show Text Two Loading...',
'Loading...',
'onTransitionStart(page transition, 1000)',
'onTransitionProgress(page transition, 1000, 2000, [suspense page])',
@@ -882,10 +878,6 @@ describe('ReactInteractionTracing', () => {
await waitForAll([
'Suspend [Page Two]',
'Suspend [Show Text One]',
'Show Text One Loading...',
'Suspend [Show Text]',
'Show Text Loading...',
'Loading...',
'onTransitionStart(navigate, 1000)',
'onTransitionStart(show text one, 1000)',
@@ -1121,8 +1113,6 @@ describe('ReactInteractionTracing', () => {
await waitForAll([
'Suspend [Page Two]',
'Suspend [Marker Text]',
'Loading...',
'Loading...',
'onTransitionStart(page transition, 1000)',
]);
@@ -1239,10 +1229,6 @@ describe('ReactInteractionTracing', () => {
await waitForAll([
'Suspend [Outer Text]',
'Suspend [Inner Text One]',
'Inner One...',
'Suspend [Inner Text Two]',
'Inner Two...',
'Outer...',
'onTransitionStart(page transition, 1000)',
'onMarkerProgress(page transition, outer marker, 1000, 2000, [outer])',
@@ -1782,8 +1768,6 @@ describe('ReactInteractionTracing', () => {
await advanceTimers(1000);
await waitForAll([
'Suspend [Page One]',
'Suspend [Child]',
'Loading Child...',
'Loading One...',
'Suspend [Page Two]',
'Loading Two...',
+5 -21
View File
@@ -352,7 +352,7 @@ describe('ReactUse', () => {
root.render(<App />);
});
});
assertLog(['CD', 'Loading...']);
assertLog(['Loading...']);
expect(root).toMatchRenderedOutput('Loading...');
});
@@ -1034,26 +1034,19 @@ describe('ReactUse', () => {
</Suspense>,
);
});
assertLog([
'Async text requested [A]',
'Async text requested [B]',
'Async text requested [C]',
'(Loading C...)',
'(Loading B...)',
'(Loading A...)',
]);
assertLog(['Async text requested [A]', '(Loading A...)']);
expect(root).toMatchRenderedOutput('(Loading A...)');
await act(() => {
resolveTextRequests('A');
});
assertLog(['A', '(Loading C...)', '(Loading B...)']);
assertLog(['A', 'Async text requested [B]', '(Loading B...)']);
expect(root).toMatchRenderedOutput('A(Loading B...)');
await act(() => {
resolveTextRequests('B');
});
assertLog(['B', '(Loading C...)']);
assertLog(['B', 'Async text requested [C]', '(Loading C...)']);
expect(root).toMatchRenderedOutput('AB(Loading C...)');
await act(() => {
@@ -1087,14 +1080,7 @@ describe('ReactUse', () => {
</Suspense>,
);
});
assertLog([
'Async text requested [A]',
'Async text requested [B]',
'Async text requested [C]',
'(Loading C...)',
'(Loading B...)',
'(Loading A...)',
]);
assertLog(['Async text requested [A]', '(Loading A...)']);
expect(root).toMatchRenderedOutput('(Loading A...)');
await act(() => {
@@ -1116,8 +1102,6 @@ describe('ReactUse', () => {
// React does not suspend on the inner requests, because that would
// block A from appearing. Instead it shows a fallback.
'Async text requested [B]',
'Async text requested [C]',
'(Loading C...)',
'(Loading B...)',
]);
expect(root).toMatchRenderedOutput('A(Loading B...)');
@@ -274,10 +274,7 @@ describe('useEffectEvent', () => {
await waitForThrow(
"A function wrapped in useEffectEvent can't be called during rendering.",
);
// If something throws, we try one more time synchronously in case the error was
// caused by a data race. See recoverFromConcurrentError
assertLog(['Count: 0', 'Count: 0']);
assertLog([]);
});
// @gate enableUseEffectEventHook
+12
View File
@@ -16,6 +16,18 @@
export const enableComponentStackLocations = true;
export const disableSchedulerTimeoutBasedOnReactExpirationTime = false;
// -----------------------------------------------------------------------------
// Killswitch
//
// Flags that exist solely to turn off a change in case it causes a regression
// when it rolls out to prod. We should remove these as soon as possible.
// -----------------------------------------------------------------------------
// This is phrased as a negative so that if someone forgets to add a GK, the
// default is to enable the feature. It should only be overridden if there's
// a regression in prod.
export const revertRemovalOfSiblingPrerendering = false;
// -----------------------------------------------------------------------------
// Land or remove (moderate effort)
//
@@ -43,6 +43,7 @@ export const enableCreateEventHandleAPI = false;
export const enableSuspenseCallback = false;
export const disableLegacyContext = false;
export const disableSchedulerTimeoutBasedOnReactExpirationTime = false;
export const revertRemovalOfSiblingPrerendering = false;
export const enableTrustedTypesIntegration = false;
export const disableTextareaChildren = false;
export const disableModulePatternComponents = false;
@@ -33,6 +33,7 @@ export const enableCreateEventHandleAPI = false;
export const enableSuspenseCallback = false;
export const disableLegacyContext = false;
export const disableSchedulerTimeoutBasedOnReactExpirationTime = false;
export const revertRemovalOfSiblingPrerendering = false;
export const enableTrustedTypesIntegration = false;
export const disableTextareaChildren = false;
export const disableModulePatternComponents = false;
@@ -33,6 +33,7 @@ export const enableCreateEventHandleAPI = false;
export const enableSuspenseCallback = false;
export const disableLegacyContext = false;
export const disableSchedulerTimeoutBasedOnReactExpirationTime = false;
export const revertRemovalOfSiblingPrerendering = false;
export const enableTrustedTypesIntegration = false;
export const disableTextareaChildren = false;
export const disableModulePatternComponents = false;
@@ -33,6 +33,7 @@ export const enableCreateEventHandleAPI = false;
export const enableSuspenseCallback = false;
export const disableLegacyContext = false;
export const disableSchedulerTimeoutBasedOnReactExpirationTime = false;
export const revertRemovalOfSiblingPrerendering = false;
export const enableTrustedTypesIntegration = false;
export const disableTextareaChildren = false;
export const disableModulePatternComponents = false;
@@ -33,6 +33,7 @@ export const enableCreateEventHandleAPI = false;
export const enableSuspenseCallback = true;
export const disableLegacyContext = false;
export const disableSchedulerTimeoutBasedOnReactExpirationTime = false;
export const revertRemovalOfSiblingPrerendering = false;
export const enableTrustedTypesIntegration = false;
export const disableTextareaChildren = false;
export const disableModulePatternComponents = true;
@@ -49,6 +49,12 @@ export const deferRenderPhaseUpdateToNextBatch = !__VARIANT__;
// so we don't need to use __VARIANT__ to get extra coverage.
export const replayFailedUnitOfWorkWithInvokeGuardedCallback = __DEV__;
// This flag only exists so it can be connected to a www GK that acts as a
// killswitch. We don't run our tests against the `true` value because 1) it
// affects too many tests 2) it shouldn't break anything. But it is mildly
// risky, hence this extra precaution.
export const revertRemovalOfSiblingPrerendering = false;
// TODO: These flags are hard-coded to the default values used in open source.
// Update the tests so that they pass in either mode, then set these
// to __VARIANT__.
@@ -19,6 +19,7 @@ export const {
disableIEWorkarounds,
enableTrustedTypesIntegration,
disableSchedulerTimeoutBasedOnReactExpirationTime,
revertRemovalOfSiblingPrerendering,
replayFailedUnitOfWorkWithInvokeGuardedCallback,
enableLegacyFBSupport,
deferRenderPhaseUpdateToNextBatch,