@samitouri / QOS-React-2 / commits / 12a1d140e3

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.

Andrew Clark committed Mar 21, 2023 at 10:24 UTC 12a1d140e366aa8d95338e4412117f16da79a078
33 files changed +476 -487
packages/internal-test-utils/__tests__/ReactInternalTestUtils-test.js
-4
@@ -99,13 +99,9 @@ describe('ReactInternalTestUtils', () => {
99 assertLog([
100 'A',
101 'B',
102 - 'C',
103 - 'D',
102 // React will try one more time before giving up.
103 'A',
104 'B',
107 - 'C',
108 - 'D',
105 ]);
106 });
107
packages/react-cache/src/__tests__/ReactCacheOld-test.internal.js
+24 -15
@@ -208,19 +208,19 @@ describe('ReactCache', () => {
208 unstable_isConcurrent: true,
209 },
210 );
211 - await waitForAll([
212 - 'Suspend! [1]',
213 - 'Suspend! [2]',
214 - 'Suspend! [3]',
215 - 'Loading...',
216 - ]);
211 + await waitForAll(['Suspend! [1]', 'Loading...']);
212 jest.advanceTimersByTime(100);
218 - assertLog([
219 - 'Promise resolved [1]',
220 - 'Promise resolved [2]',
221 - 'Promise resolved [3]',
222 - ]);
213 + assertLog(['Promise resolved [1]']);
214 + await waitForAll([1, 'Suspend! [2]']);
215 +
216 + jest.advanceTimersByTime(100);
217 + assertLog(['Promise resolved [2]']);
218 + await waitForAll([1, 2, 'Suspend! [3]']);
219 +
220 + jest.advanceTimersByTime(100);
221 + assertLog(['Promise resolved [3]']);
222 await waitForAll([1, 2, 3]);
223 +
224 expect(root).toMatchRenderedOutput('123');
225
226 // Render 1, 4, 5
@@ -232,10 +232,16 @@ describe('ReactCache', () => {
232 </Suspense>,
233 );
234
235 - await waitForAll([1, 'Suspend! [4]', 'Suspend! [5]', 'Loading...']);
235 + await waitForAll([1, 'Suspend! [4]', 'Loading...']);
236 +
237 + jest.advanceTimersByTime(100);
238 + assertLog(['Promise resolved [4]']);
239 + await waitForAll([1, 4, 'Suspend! [5]', 'Loading...']);
240 +
241 jest.advanceTimersByTime(100);
237 - assertLog(['Promise resolved [4]', 'Promise resolved [5]']);
242 + assertLog(['Promise resolved [5]']);
243 await waitForAll([1, 4, 5]);
244 +
245 expect(root).toMatchRenderedOutput('145');
246
247 // We've now rendered values 1, 2, 3, 4, 5, over our limit of 3. The least
@@ -254,11 +260,14 @@ describe('ReactCache', () => {
260 1,
261 // 2 and 3 suspend because they were evicted from the cache
262 'Suspend! [2]',
257 - 'Suspend! [3]',
263 'Loading...',
264 ]);
265 jest.advanceTimersByTime(100);
261 - assertLog(['Promise resolved [2]', 'Promise resolved [3]']);
266 + assertLog(['Promise resolved [2]']);
267 + await waitForAll([1, 2, 'Suspend! [3]', 'Loading...']);
268 +
269 + jest.advanceTimersByTime(100);
270 + assertLog(['Promise resolved [3]']);
271 await waitForAll([1, 2, 3]);
272 expect(root).toMatchRenderedOutput('123');
273 });
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+110 -109
@@ -2949,9 +2949,6 @@ describe('ReactDOMFizzServer', () => {
2949 // blocking the main thread.
2950 shouldThrow = false;
2951 await waitForAll([
2952 - // Finish initial render attempt
2953 - 'B',
2954 -
2952 // Render again, synchronously
2953 'A',
2954 'B',
@@ -3865,8 +3862,6 @@ describe('ReactDOMFizzServer', () => {
3862 },
3863 });
3864 await waitForAll([
3868 - 'Logged recoverable error: Text content does not match server-rendered HTML.',
3869 - 'Logged recoverable error: Text content does not match server-rendered HTML.',
3865 'Logged recoverable error: Text content does not match server-rendered HTML.',
3866 'Logged recoverable error: There was an error while hydrating this Suspense boundary. Switched to client rendering.',
3867 ]);
@@ -3902,32 +3897,32 @@ describe('ReactDOMFizzServer', () => {
3897
3898 it('supresses hydration warnings when an error occurs within a Suspense boundary', async () => {
3899 let isClient = false;
3905 - let shouldThrow = true;
3900
3907 - function ThrowUntilOnClient({children}) {
3908 - if (isClient && shouldThrow) {
3909 - throw new Error('uh oh');
3910 - }
3901 + function ThrowWhenHydrating({children}) {
3902 + // This is a trick to only throw if we're hydrating, because
3903 + // useSyncExternalStore calls getServerSnapshot instead of the regular
3904 + // getSnapshot in that case.
3905 + useSyncExternalStore(
3906 + () => {},
3907 + t => t,
3908 + () => {
3909 + if (isClient) {
3910 + throw new Error('uh oh');
3911 + }
3912 + },
3913 + );
3914 return children;
3915 }
3916
3914 - function StopThrowingOnClient() {
3915 - if (isClient) {
3916 - shouldThrow = false;
3917 - }
3918 - return null;
3919 - }
3920 -
3917 const App = () => {
3918 return (
3919 <div>
3920 <Suspense fallback={<h1>Loading...</h1>}>
3925 - <ThrowUntilOnClient>
3921 + <ThrowWhenHydrating>
3922 <h1>one</h1>
3927 - </ThrowUntilOnClient>
3923 + </ThrowWhenHydrating>
3924 <h2>two</h2>
3925 <h3>{isClient ? 'five' : 'three'}</h3>
3930 - <StopThrowingOnClient />
3926 </Suspense>
3927 </div>
3928 );
@@ -3955,8 +3950,6 @@ describe('ReactDOMFizzServer', () => {
3950 });
3951 await waitForAll([
3952 'Logged recoverable error: uh oh',
3958 - 'Logged recoverable error: Hydration failed because the initial UI does not match what was rendered on the server.',
3959 - 'Logged recoverable error: Hydration failed because the initial UI does not match what was rendered on the server.',
3953 'Logged recoverable error: There was an error while hydrating this Suspense boundary. Switched to client rendering.',
3954 ]);
3955
@@ -3986,37 +3979,37 @@ describe('ReactDOMFizzServer', () => {
3979 mockError(...args.map(normalizeCodeLocInfo));
3980 };
3981 let isClient = false;
3989 - let shouldThrow = true;
3982
3991 - function ThrowUntilOnClient({children, message}) {
3992 - if (isClient && shouldThrow) {
3993 - Scheduler.log('throwing: ' + message);
3994 - throw new Error(message);
3995 - }
3983 + function ThrowWhenHydrating({children, message}) {
3984 + // This is a trick to only throw if we're hydrating, because
3985 + // useSyncExternalStore calls getServerSnapshot instead of the regular
3986 + // getSnapshot in that case.
3987 + useSyncExternalStore(
3988 + () => {},
3989 + t => t,
3990 + () => {
3991 + if (isClient) {
3992 + Scheduler.log('throwing: ' + message);
3993 + throw new Error(message);
3994 + }
3995 + },
3996 + );
3997 return children;
3998 }
3999
3999 - function StopThrowingOnClient() {
4000 - if (isClient) {
4001 - shouldThrow = false;
4002 - }
4003 - return null;
4004 - }
4005 -
4000 const App = () => {
4001 return (
4002 <div>
4003 <Suspense fallback={<h1>Loading...</h1>}>
4010 - <ThrowUntilOnClient message="first error">
4004 + <ThrowWhenHydrating message="first error">
4005 <h1>one</h1>
4012 - </ThrowUntilOnClient>
4013 - <ThrowUntilOnClient message="second error">
4006 + </ThrowWhenHydrating>
4007 + <ThrowWhenHydrating message="second error">
4008 <h2>two</h2>
4015 - </ThrowUntilOnClient>
4016 - <ThrowUntilOnClient message="third error">
4009 + </ThrowWhenHydrating>
4010 + <ThrowWhenHydrating message="third error">
4011 <h3>three</h3>
4018 - </ThrowUntilOnClient>
4019 - <StopThrowingOnClient />
4012 + </ThrowWhenHydrating>
4013 </Suspense>
4014 </div>
4015 );
@@ -4047,14 +4040,10 @@ describe('ReactDOMFizzServer', () => {
4040 'throwing: first error',
4041 // this repeated first error is the invokeGuardedCallback throw
4042 'throwing: first error',
4050 - // these are actually thrown during render but no iGC repeat and no queueing as hydration errors
4051 - 'throwing: second error',
4052 - 'throwing: third error',
4053 - // all hydration errors are still queued
4043 +
4044 + // onRecoverableError because the UI recovered without surfacing the
4045 + // error to the user.
4046 'Logged recoverable error: first error',
4055 - 'Logged recoverable error: second error',
4056 - 'Logged recoverable error: third error',
4057 - // other recoverable errors are queued as hydration errors
4047 'Logged recoverable error: There was an error while hydrating this Suspense boundary. Switched to client rendering.',
4048 ]);
4049 // These Uncaught error calls are the error reported by the runtime (jsdom here, browser in actual use)
@@ -4081,6 +4070,7 @@ describe('ReactDOMFizzServer', () => {
4070 }
4071 });
4072
4073 + // @gate __DEV__
4074 it('does not invokeGuardedCallback for errors after a preceding fiber suspends', async () => {
4075 // We can't use the toErrorDev helper here because this is async.
4076 const originalConsoleError = console.error;
@@ -4095,7 +4085,6 @@ describe('ReactDOMFizzServer', () => {
4085 mockError(...args.map(normalizeCodeLocInfo));
4086 };
4087 let isClient = false;
4098 - let shouldThrow = true;
4088 let promise = null;
4089 let unsuspend = null;
4090 let isResolved = false;
@@ -4116,36 +4105,37 @@ describe('ReactDOMFizzServer', () => {
4105 return null;
4106 }
4107
4119 - function ThrowUntilOnClient({children, message}) {
4120 - if (isClient && shouldThrow) {
4121 - Scheduler.log('throwing: ' + message);
4122 - throw new Error(message);
4123 - }
4108 + function ThrowWhenHydrating({children, message}) {
4109 + // This is a trick to only throw if we're hydrating, because
4110 + // useSyncExternalStore calls getServerSnapshot instead of the regular
4111 + // getSnapshot in that case.
4112 + useSyncExternalStore(
4113 + () => {},
4114 + t => t,
4115 + () => {
4116 + if (isClient) {
4117 + Scheduler.log('throwing: ' + message);
4118 + throw new Error(message);
4119 + }
4120 + },
4121 + );
4122 return children;
4123 }
4124
4127 - function StopThrowingOnClient() {
4128 - if (isClient) {
4129 - shouldThrow = false;
4130 - }
4131 - return null;
4132 - }
4133 -
4125 const App = () => {
4126 return (
4127 <div>
4128 <Suspense fallback={<h1>Loading...</h1>}>
4129 <ComponentThatSuspendsOnClient />
4139 - <ThrowUntilOnClient message="first error">
4130 + <ThrowWhenHydrating message="first error">
4131 <h1>one</h1>
4141 - </ThrowUntilOnClient>
4142 - <ThrowUntilOnClient message="second error">
4132 + </ThrowWhenHydrating>
4133 + <ThrowWhenHydrating message="second error">
4134 <h2>two</h2>
4144 - </ThrowUntilOnClient>
4145 - <ThrowUntilOnClient message="third error">
4135 + </ThrowWhenHydrating>
4136 + <ThrowWhenHydrating message="third error">
4137 <h3>three</h3>
4147 - </ThrowUntilOnClient>
4148 - <StopThrowingOnClient />
4138 + </ThrowWhenHydrating>
4139 </Suspense>
4140 </div>
4141 );
@@ -4172,15 +4162,7 @@ describe('ReactDOMFizzServer', () => {
4162 Scheduler.log('Logged recoverable error: ' + error.message);
4163 },
4164 });
4175 - await waitForAll([
4176 - 'suspending',
4177 - 'throwing: first error',
4178 - // There is no repeated first error because we already suspended and no
4179 - // invokeGuardedCallback is used if we are in dev
4180 - // or in prod there is just never an invokeGuardedCallback
4181 - 'throwing: second error',
4182 - 'throwing: third error',
4183 - ]);
4165 + await waitForAll(['suspending']);
4166 expect(mockError.mock.calls).toEqual([]);
4167
4168 expect(getVisibleChildren(container)).toEqual(
@@ -4191,18 +4173,31 @@ describe('ReactDOMFizzServer', () => {
4173 </div>,
4174 );
4175 await unsuspend();
4194 - // Since our client components only throw on the very first render there are no
4195 - // new throws in this pass
4196 - await waitForAll([]);
4197 -
4198 - expect(mockError.mock.calls).toEqual([]);
4176 + await waitForAll([
4177 + 'throwing: first error',
4178 + 'throwing: first error',
4179 + 'Logged recoverable error: first error',
4180 + 'Logged recoverable error: There was an error while hydrating this Suspense boundary. Switched to client rendering.',
4181 + ]);
4182 + expect(getVisibleChildren(container)).toEqual(
4183 + <div>
4184 + <h1>one</h1>
4185 + <h2>two</h2>
4186 + <h3>three</h3>
4187 + </div>,
4188 + );
4189 } finally {
4190 console.error = originalConsoleError;
4191 }
4192 });
4193
4194 // @gate __DEV__
4205 - it('suspending after erroring will cause errors previously queued to be silenced until the boundary resolves', async () => {
4195 + it('(outdated behavior) suspending after erroring will cause errors previously queued to be silenced until the boundary resolves', async () => {
4196 + // NOTE: This test was originally written to test a scenario that doesn't happen
4197 + // anymore. If something errors during hydration, we immediately unwind the
4198 + // stack and revert to client rendering. I've kept the test around just to
4199 + // demonstrate what actually happens in this sequence of events.
4200 +
4201 // We can't use the toErrorDev helper here because this is async.
4202 const originalConsoleError = console.error;
4203 const mockError = jest.fn();
@@ -4216,7 +4211,6 @@ describe('ReactDOMFizzServer', () => {
4211 mockError(...args.map(normalizeCodeLocInfo));
4212 };
4213 let isClient = false;
4219 - let shouldThrow = true;
4214 let promise = null;
4215 let unsuspend = null;
4216 let isResolved = false;
@@ -4237,36 +4231,37 @@ describe('ReactDOMFizzServer', () => {
4231 return null;
4232 }
4233
4240 - function ThrowUntilOnClient({children, message}) {
4241 - if (isClient && shouldThrow) {
4242 - Scheduler.log('throwing: ' + message);
4243 - throw new Error(message);
4244 - }
4234 + function ThrowWhenHydrating({children, message}) {
4235 + // This is a trick to only throw if we're hydrating, because
4236 + // useSyncExternalStore calls getServerSnapshot instead of the regular
4237 + // getSnapshot in that case.
4238 + useSyncExternalStore(
4239 + () => {},
4240 + t => t,
4241 + () => {
4242 + if (isClient) {
4243 + Scheduler.log('throwing: ' + message);
4244 + throw new Error(message);
4245 + }
4246 + },
4247 + );
4248 return children;
4249 }
4250
4248 - function StopThrowingOnClient() {
4249 - if (isClient) {
4250 - shouldThrow = false;
4251 - }
4252 - return null;
4253 - }
4254 -
4251 const App = () => {
4252 return (
4253 <div>
4254 <Suspense fallback={<h1>Loading...</h1>}>
4259 - <ThrowUntilOnClient message="first error">
4255 + <ThrowWhenHydrating message="first error">
4256 <h1>one</h1>
4261 - </ThrowUntilOnClient>
4262 - <ThrowUntilOnClient message="second error">
4257 + </ThrowWhenHydrating>
4258 + <ThrowWhenHydrating message="second error">
4259 <h2>two</h2>
4264 - </ThrowUntilOnClient>
4260 + </ThrowWhenHydrating>
4261 <ComponentThatSuspendsOnClient />
4266 - <ThrowUntilOnClient message="third error">
4262 + <ThrowWhenHydrating message="third error">
4263 <h3>three</h3>
4268 - </ThrowUntilOnClient>
4269 - <StopThrowingOnClient />
4264 + </ThrowWhenHydrating>
4265 </Suspense>
4266 </div>
4267 );
@@ -4297,9 +4292,9 @@ describe('ReactDOMFizzServer', () => {
4292 'throwing: first error',
4293 // duplicate because first error is re-done in invokeGuardedCallback
4294 'throwing: first error',
4300 - 'throwing: second error',
4295 'suspending',
4302 - 'throwing: third error',
4296 + 'Logged recoverable error: first error',
4297 + 'Logged recoverable error: There was an error while hydrating this Suspense boundary. Switched to client rendering.',
4298 ]);
4299 // These Uncaught error calls are the error reported by the runtime (jsdom here, browser in actual use)
4300 // when invokeGuardedCallback is used to replay an error in dev using event dispatching in the document
@@ -4312,9 +4307,7 @@ describe('ReactDOMFizzServer', () => {
4307
4308 expect(getVisibleChildren(container)).toEqual(
4309 <div>
4315 - <h1>one</h1>
4316 - <h2>two</h2>
4317 - <h3>three</h3>
4310 + <h1>Loading...</h1>
4311 </div>,
4312 );
4313 await unsuspend();
@@ -4322,6 +4315,14 @@ describe('ReactDOMFizzServer', () => {
4315 // new throws in this pass
4316 await waitForAll([]);
4317 expect(mockError.mock.calls).toEqual([]);
4318 +
4319 + expect(getVisibleChildren(container)).toEqual(
4320 + <div>
4321 + <h1>one</h1>
4322 + <h2>two</h2>
4323 + <h3>three</h3>
4324 + </div>,
4325 + );
4326 } finally {
4327 console.error = originalConsoleError;
4328 }
packages/react-dom/src/__tests__/ReactDOMFizzSuppressHydrationWarning-test.js
-1
@@ -479,7 +479,6 @@ describe('ReactDOMFizzServerHydrationWarning', () => {
479 });
480 await expect(async () => {
481 await waitForAll([
482 - 'Hydration failed because the initial UI does not match what was rendered on the server.',
482 'Hydration failed because the initial UI does not match what was rendered on the server.',
483 'There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.',
484 ]);
packages/react-dom/src/__tests__/ReactDOMHydrationDiff-test.js
+30 -34
@@ -488,17 +488,16 @@ describe('ReactDOMServerHydration', () => {
488 );
489 }
490 expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
491 - [
492 - "Warning: Expected server HTML to contain a matching <main> in <div>.
493 - in main (at **)
494 - in div (at **)
495 - in Mismatch (at **)",
496 - "Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.",
497 - "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
498 - "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
499 - "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
500 - ]
501 - `);
491 + [
492 + "Warning: Expected server HTML to contain a matching <main> in <div>.
493 + in main (at **)
494 + in div (at **)
495 + in Mismatch (at **)",
496 + "Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.",
497 + "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
498 + "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
499 + ]
500 + `);
501 });
502
503 // @gate __DEV__
@@ -579,17 +578,16 @@ describe('ReactDOMServerHydration', () => {
578 );
579 }
580 expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
582 - [
583 - "Warning: Expected server HTML to contain a matching <main> in <div>.
584 - in main (at **)
585 - in div (at **)
586 - in Mismatch (at **)",
587 - "Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.",
588 - "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
589 - "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
590 - "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
591 - ]
592 - `);
581 + [
582 + "Warning: Expected server HTML to contain a matching <main> in <div>.
583 + in main (at **)
584 + in div (at **)
585 + in Mismatch (at **)",
586 + "Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.",
587 + "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
588 + "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
589 + ]
590 + `);
591 });
592
593 // @gate __DEV__
@@ -872,18 +870,16 @@ describe('ReactDOMServerHydration', () => {
870 );
871 }
872 expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
875 - [
876 - "Warning: Expected server HTML to contain a matching <header> in <div>.
877 - in header (at **)
878 - in div (at **)
879 - in Mismatch (at **)",
880 - "Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.",
881 - "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
882 - "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
883 - "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
884 - "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
885 - ]
886 - `);
873 + [
874 + "Warning: Expected server HTML to contain a matching <header> in <div>.
875 + in header (at **)
876 + in div (at **)
877 + in Mismatch (at **)",
878 + "Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.",
879 + "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
880 + "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
881 + ]
882 + `);
883 });
884
885 // @gate __DEV__
packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js
+2 -8
@@ -312,13 +312,7 @@ describe('ReactDOMServerPartialHydration', () => {
312 Scheduler.log(error.message);
313 },
314 });
315 - await waitForAll([
316 - 'Suspend',
317 - 'Component',
318 - 'Component',
319 - 'Component',
320 - 'Component',
321 - ]);
315 + await waitForAll(['Suspend']);
316 jest.runAllTimers();
317
318 // Unchanged
@@ -1415,7 +1409,7 @@ describe('ReactDOMServerPartialHydration', () => {
1409 );
1410
1411 // This will throw it away and rerender.
1418 - await waitForAll(['Child', 'Sibling']);
1412 + await waitForAll(['Child']);
1413
1414 expect(container.textContent).toBe('Hello');
1415
packages/react-dom/src/__tests__/ReactErrorBoundaries-test.internal.js
+1 -4
@@ -1000,10 +1000,7 @@ describe('ReactErrorBoundaries', () => {
1000 'BrokenRender constructor',
1001 'BrokenRender componentWillMount',
1002 'BrokenRender render [!]',
1003 - // Render third child, even though an earlier sibling threw.
1004 - 'Normal constructor',
1005 - 'Normal componentWillMount',
1006 - 'Normal render',
1003 + // Skip the remaining siblings
1004 // Handle the error
1005 'ErrorBoundary static getDerivedStateFromError',
1006 'ErrorBoundary componentWillMount',
packages/react-dom/src/__tests__/ReactLegacyErrorBoundaries-test.internal.js
+1 -4
@@ -1040,10 +1040,7 @@ describe('ReactLegacyErrorBoundaries', () => {
1040 'BrokenRender constructor',
1041 'BrokenRender componentWillMount',
1042 'BrokenRender render [!]',
1043 - // Render third child, even though an earlier sibling threw.
1044 - 'Normal constructor',
1045 - 'Normal componentWillMount',
1046 - 'Normal render',
1043 + // Skip the remaining siblings
1044 // Finish mounting with null children
1045 'ErrorBoundary componentDidMount',
1046 // Handle the error
packages/react-reconciler/src/ReactFiberWorkLoop.js
+150 -76
@@ -39,6 +39,7 @@ import {
39 enableCache,
40 enableTransitionTracing,
41 useModernStrictMode,
42 + revertRemovalOfSiblingPrerendering,
43 } from 'shared/ReactFeatureFlags';
44 import ReactSharedInternals from 'shared/ReactSharedInternals';
45 import is from 'shared/objectIs';
@@ -2173,10 +2174,10 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) {
2174 break outer;
2175 }
2176 default: {
2176 - // Continue with the normal work loop.
2177 + // Unwind then continue with the normal work loop.
2178 workInProgressSuspendedReason = NotSuspended;
2179 workInProgressThrownValue = null;
2179 - unwindSuspendedUnitOfWork(unitOfWork, thrownValue);
2180 + throwAndUnwindWorkLoop(unitOfWork, thrownValue);
2181 break;
2182 }
2183 }
@@ -2285,7 +2286,7 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
2286 // Unwind then continue with the normal work loop.
2287 workInProgressSuspendedReason = NotSuspended;
2288 workInProgressThrownValue = null;
2288 - unwindSuspendedUnitOfWork(unitOfWork, thrownValue);
2289 + throwAndUnwindWorkLoop(unitOfWork, thrownValue);
2290 break;
2291 }
2292 case SuspendedOnData: {
@@ -2343,7 +2344,7 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
2344 // Otherwise, unwind then continue with the normal work loop.
2345 workInProgressSuspendedReason = NotSuspended;
2346 workInProgressThrownValue = null;
2346 - unwindSuspendedUnitOfWork(unitOfWork, thrownValue);
2347 + throwAndUnwindWorkLoop(unitOfWork, thrownValue);
2348 }
2349 break;
2350 }
@@ -2400,7 +2401,7 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
2401 // Otherwise, unwind then continue with the normal work loop.
2402 workInProgressSuspendedReason = NotSuspended;
2403 workInProgressThrownValue = null;
2403 - unwindSuspendedUnitOfWork(unitOfWork, thrownValue);
2404 + throwAndUnwindWorkLoop(unitOfWork, thrownValue);
2405 break;
2406 }
2407 case SuspendedOnDeprecatedThrowPromise: {
@@ -2410,7 +2411,7 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
2411 // always unwind.
2412 workInProgressSuspendedReason = NotSuspended;
2413 workInProgressThrownValue = null;
2413 - unwindSuspendedUnitOfWork(unitOfWork, thrownValue);
2414 + throwAndUnwindWorkLoop(unitOfWork, thrownValue);
2415 break;
2416 }
2417 case SuspendedOnHydration: {
@@ -2610,7 +2611,7 @@ function replaySuspendedUnitOfWork(unitOfWork: Fiber): void {
2611 ReactCurrentOwner.current = null;
2612 }
2613
2613 -function unwindSuspendedUnitOfWork(unitOfWork: Fiber, thrownValue: mixed) {
2614 +function throwAndUnwindWorkLoop(unitOfWork: Fiber, thrownValue: mixed) {
2615 // This is a fork of performUnitOfWork specifcally for unwinding a fiber
2616 // that threw an exception.
2617 //
@@ -2655,8 +2656,21 @@ function unwindSuspendedUnitOfWork(unitOfWork: Fiber, thrownValue: mixed) {
2656 throw error;
2657 }
2658
2658 - // Return to the normal work loop.
2659 - completeUnitOfWork(unitOfWork);
2659 + if (unitOfWork.flags & Incomplete) {
2660 + // Unwind the stack until we reach the nearest boundary.
2661 + unwindUnitOfWork(unitOfWork);
2662 + } else {
2663 + // Although the fiber suspended, we're intentionally going to commit it in
2664 + // an inconsistent state. We can do this safely in cases where we know the
2665 + // inconsistent tree will be hidden.
2666 + //
2667 + // This currently only applies to Legacy Suspense implementation, but we may
2668 + // port a version of this to concurrent roots, too, when performing a
2669 + // synchronous render. Because that will allow us to mutate the tree as we
2670 + // go instead of buffering mutations until the end. Though it's unclear if
2671 + // this particular path is how that would be implemented.
2672 + completeUnitOfWork(unitOfWork);
2673 + }
2674 }
2675
2676 function completeUnitOfWork(unitOfWork: Fiber): void {
@@ -2664,81 +2678,53 @@ function completeUnitOfWork(unitOfWork: Fiber): void {
2678 // sibling. If there are no more siblings, return to the parent fiber.
2679 let completedWork: Fiber = unitOfWork;
2680 do {
2681 + if (revertRemovalOfSiblingPrerendering) {
2682 + if ((completedWork.flags & Incomplete) !== NoFlags) {
2683 + // This fiber did not complete, because one of its children did not
2684 + // complete. Switch to unwinding the stack instead of completing it.
2685 + //
2686 + // The reason "unwind" and "complete" is interleaved is because when
2687 + // something suspends, we continue rendering the siblings even though
2688 + // they will be replaced by a fallback.
2689 + // TODO: Disable sibling prerendering, then remove this branch.
2690 + unwindUnitOfWork(completedWork);
2691 + return;
2692 + }
2693 + } else {
2694 + if (__DEV__) {
2695 + if ((completedWork.flags & Incomplete) !== NoFlags) {
2696 + // NOTE: If we re-enable sibling prerendering in some cases, this branch
2697 + // is where we would switch to the unwinding path.
2698 + console.error(
2699 + 'Internal React error: Expected this fiber to be complete, but ' +
2700 + "it isn't. It should have been unwound. This is a bug in React.",
2701 + );
2702 + }
2703 + }
2704 + }
2705 +
2706 // The current, flushed, state of this fiber is the alternate. Ideally
2707 // nothing should rely on this, but relying on it here means that we don't
2708 // need an additional field on the work in progress.
2709 const current = completedWork.alternate;
2710 const returnFiber = completedWork.return;
2711
2673 - // Check if the work completed or if something threw.
2674 - if ((completedWork.flags & Incomplete) === NoFlags) {
2675 - setCurrentDebugFiberInDEV(completedWork);
2676 - let next;
2677 - if (
2678 - !enableProfilerTimer ||
2679 - (completedWork.mode & ProfileMode) === NoMode
2680 - ) {
2681 - next = completeWork(current, completedWork, renderLanes);
2682 - } else {
2683 - startProfilerTimer(completedWork);
2684 - next = completeWork(current, completedWork, renderLanes);
2685 - // Update render duration assuming we didn't error.
2686 - stopProfilerTimerIfRunningAndRecordDelta(completedWork, false);
2687 - }
2688 - resetCurrentDebugFiberInDEV();
2689 -
2690 - if (next !== null) {
2691 - // Completing this fiber spawned new work. Work on that next.
2692 - workInProgress = next;
2693 - return;
2694 - }
2712 + setCurrentDebugFiberInDEV(completedWork);
2713 + let next;
2714 + if (!enableProfilerTimer || (completedWork.mode & ProfileMode) === NoMode) {
2715 + next = completeWork(current, completedWork, renderLanes);
2716 } else {
2696 - // This fiber did not complete because something threw. Pop values off
2697 - // the stack without entering the complete phase. If this is a boundary,
2698 - // capture values if possible.
2699 - const next = unwindWork(current, completedWork, renderLanes);
2700 -
2701 - // Because this fiber did not complete, don't reset its lanes.
2702 -
2703 - if (next !== null) {
2704 - // If completing this work spawned new work, do that next. We'll come
2705 - // back here again.
2706 - // Since we're restarting, remove anything that is not a host effect
2707 - // from the effect tag.
2708 - next.flags &= HostEffectMask;
2709 - workInProgress = next;
2710 - return;
2711 - }
2712 -
2713 - if (
2714 - enableProfilerTimer &&
2715 - (completedWork.mode & ProfileMode) !== NoMode
2716 - ) {
2717 - // Record the render duration for the fiber that errored.
2718 - stopProfilerTimerIfRunningAndRecordDelta(completedWork, false);
2719 -
2720 - // Include the time spent working on failed children before continuing.
2721 - let actualDuration = completedWork.actualDuration;
2722 - let child = completedWork.child;
2723 - while (child !== null) {
2724 - // $FlowFixMe[unsafe-addition] addition with possible null/undefined value
2725 - actualDuration += child.actualDuration;
2726 - child = child.sibling;
2727 - }
2728 - completedWork.actualDuration = actualDuration;
2729 - }
2717 + startProfilerTimer(completedWork);
2718 + next = completeWork(current, completedWork, renderLanes);
2719 + // Update render duration assuming we didn't error.
2720 + stopProfilerTimerIfRunningAndRecordDelta(completedWork, false);
2721 + }
2722 + resetCurrentDebugFiberInDEV();
2723
2731 - if (returnFiber !== null) {
2732 - // Mark the parent fiber as incomplete and clear its subtree flags.
2733 - returnFiber.flags |= Incomplete;
2734 - returnFiber.subtreeFlags = NoFlags;
2735 - returnFiber.deletions = null;
2736 - } else {
2737 - // We've unwound all the way to the root.
2738 - workInProgressRootExitStatus = RootDidNotComplete;
2739 - workInProgress = null;
2740 - return;
2741 - }
2724 + if (next !== null) {
2725 + // Completing this fiber spawned new work. Work on that next.
2726 + workInProgress = next;
2727 + return;
2728 }
2729
2730 const siblingFiber = completedWork.sibling;
@@ -2760,6 +2746,94 @@ function completeUnitOfWork(unitOfWork: Fiber): void {
2746 }
2747 }
2748
2749 +function unwindUnitOfWork(unitOfWork: Fiber): void {
2750 + let incompleteWork: Fiber = unitOfWork;
2751 + do {
2752 + // The current, flushed, state of this fiber is the alternate. Ideally
2753 + // nothing should rely on this, but relying on it here means that we don't
2754 + // need an additional field on the work in progress.
2755 + const current = incompleteWork.alternate;
2756 +
2757 + // This fiber did not complete because something threw. Pop values off
2758 + // the stack without entering the complete phase. If this is a boundary,
2759 + // capture values if possible.
2760 + const next = unwindWork(current, incompleteWork, renderLanes);
2761 +
2762 + // Because this fiber did not complete, don't reset its lanes.
2763 +
2764 + if (next !== null) {
2765 + // Found a boundary that can handle this exception. Re-renter the
2766 + // begin phase. This branch will return us to the normal work loop.
2767 + //
2768 + // Since we're restarting, remove anything that is not a host effect
2769 + // from the effect tag.
2770 + next.flags &= HostEffectMask;
2771 + workInProgress = next;
2772 + return;
2773 + }
2774 +
2775 + // Keep unwinding until we reach either a boundary or the root.
2776 +
2777 + if (enableProfilerTimer && (incompleteWork.mode & ProfileMode) !== NoMode) {
2778 + // Record the render duration for the fiber that errored.
2779 + stopProfilerTimerIfRunningAndRecordDelta(incompleteWork, false);
2780 +
2781 + // Include the time spent working on failed children before continuing.
2782 + let actualDuration = incompleteWork.actualDuration;
2783 + let child = incompleteWork.child;
2784 + while (child !== null) {
2785 + // $FlowFixMe[unsafe-addition] addition with possible null/undefined value
2786 + actualDuration += child.actualDuration;
2787 + child = child.sibling;
2788 + }
2789 + incompleteWork.actualDuration = actualDuration;
2790 + }
2791 +
2792 + // TODO: Once we stop prerendering siblings, instead of resetting the parent
2793 + // of the node being unwound, we should be able to reset node itself as we
2794 + // unwind the stack. Saves an additional null check.
2795 + const returnFiber = incompleteWork.return;
2796 + if (returnFiber !== null) {
2797 + // Mark the parent fiber as incomplete and clear its subtree flags.
2798 + // TODO: Once we stop prerendering siblings, we may be able to get rid of
2799 + // the Incomplete flag because unwinding to the nearest boundary will
2800 + // happen synchronously.
2801 + returnFiber.flags |= Incomplete;
2802 + returnFiber.subtreeFlags = NoFlags;
2803 + returnFiber.deletions = null;
2804 + }
2805 +
2806 + if (revertRemovalOfSiblingPrerendering) {
2807 + // If there are siblings, work on them now even though they're going to be
2808 + // replaced by a fallback. We're "prerendering" them. Historically our
2809 + // rationale for this behavior has been to initiate any lazy data requests
2810 + // in the siblings, and also to warm up the CPU cache.
2811 + // TODO: Don't prerender siblings. With `use`, we suspend the work loop
2812 + // until the data has resolved, anyway.
2813 + const siblingFiber = incompleteWork.sibling;
2814 + if (siblingFiber !== null) {
2815 + // This branch will return us to the normal work loop.
2816 + workInProgress = siblingFiber;
2817 + return;
2818 + }
2819 + } else {
2820 + // NOTE: If we re-enable sibling prerendering in some cases, this branch
2821 + // is where we would switch to the normal completion path: check if a
2822 + // sibling exists, and if so, begin work on it.
2823 + }
2824 +
2825 + // Otherwise, return to the parent
2826 + // $FlowFixMe[incompatible-type] we bail out when we get a null
2827 + incompleteWork = returnFiber;
2828 + // Update the next thing we're working on in case something throws.
2829 + workInProgress = incompleteWork;
2830 + } while (incompleteWork !== null);
2831 +
2832 + // We've unwound all the way to the root.
2833 + workInProgressRootExitStatus = RootDidNotComplete;
2834 + workInProgress = null;
2835 +}
2836 +
2837 function commitRoot(
2838 root: FiberRoot,
2839 recoverableErrors: null | Array<CapturedValue<mixed>>,
packages/react-reconciler/src/__tests__/ReactBatching-test.internal.js
+1 -1
@@ -111,7 +111,7 @@ describe('ReactBlockingMode', () => {
111 </Suspense>,
112 );
113
114 - await waitForAll(['A', 'Suspend! [B]', 'C', 'Loading...']);
114 + await waitForAll(['A', 'Suspend! [B]', 'Loading...']);
115 // In Legacy Mode, A and B would mount in a hidden primary tree. In
116 // Concurrent Mode, nothing in the primary tree should mount. But the
117 // fallback should mount immediately.
packages/react-reconciler/src/__tests__/ReactCache-test.js
+1 -1
@@ -1057,7 +1057,7 @@ describe('ReactCache', () => {
1057 await act(() => {
1058 root.render(<App showMore={false} />);
1059 });
1060 - assertLog(['Cache miss! [A]', 'Cache miss! [B]', 'Loading...']);
1060 + assertLog(['Cache miss! [A]', 'Loading...']);
1061 expect(root).toMatchRenderedOutput('Loading...');
1062
1063 await act(() => {
packages/react-reconciler/src/__tests__/ReactConcurrentErrorRecovery-test.js
+13 -15
@@ -450,20 +450,16 @@ describe('ReactConcurrentErrorRecovery', () => {
450 await act(() => {
451 startTransition(() => {
452 root.render(
453 - <ErrorBoundary>
453 + <>
454 <AsyncText text="Async" />
455 - <Throws />
456 - </ErrorBoundary>,
455 + <ErrorBoundary>
456 + <Throws />
457 + </ErrorBoundary>
458 + </>,
459 );
460 });
461 });
460 - assertLog([
461 - 'Suspend! [Async]',
462 - // TODO: Ideally we would skip this second render pass to render the
463 - // error UI, since it's not going to commit anyway. The same goes for
464 - // Suspense fallbacks during a refresh transition.
465 - 'Caught an error: Oops!',
466 - ]);
462 + assertLog(['Suspend! [Async]']);
463 // The render suspended without committing or surfacing the error.
464 expect(root).toMatchRenderedOutput(null);
465
@@ -471,14 +467,16 @@ describe('ReactConcurrentErrorRecovery', () => {
467 await act(() => {
468 startTransition(() => {
469 root.render(
474 - <ErrorBoundary>
475 - <Throws />
470 + <>
471 <AsyncText text="Async" />
477 - </ErrorBoundary>,
472 + <ErrorBoundary>
473 + <Throws />
474 + </ErrorBoundary>
475 + </>,
476 );
477 });
478 });
481 - assertLog(['Suspend! [Async]', 'Caught an error: Oops!']);
479 + assertLog(['Suspend! [Async]']);
480 expect(root).toMatchRenderedOutput(null);
481
482 await act(async () => {
@@ -494,7 +492,7 @@ describe('ReactConcurrentErrorRecovery', () => {
492 'Caught an error: Oops!',
493 ]);
494
497 - expect(root).toMatchRenderedOutput('Caught an error: Oops!');
495 + expect(root).toMatchRenderedOutput('AsyncCaught an error: Oops!');
496 },
497 );
498 });
packages/react-reconciler/src/__tests__/ReactDisableSchedulerTimeoutBasedOnReactExpirationTime-test.internal.js
+1 -1
@@ -72,7 +72,7 @@ describe('ReactSuspenseList', () => {
72 React.startTransition(() => {
73 root.render(<App show={true} />);
74 });
75 - await waitForAll(['Suspend! [A]', 'Suspend! [B]', 'Loading...']);
75 + await waitForAll(['Suspend! [A]', 'Loading...']);
76 expect(root).toMatchRenderedOutput(null);
77
78 Scheduler.unstable_advanceTime(2000);
packages/react-reconciler/src/__tests__/ReactExpiration-test.js
+1 -1
@@ -647,7 +647,7 @@ describe('ReactExpiration', () => {
647 React.startTransition(() => {
648 root.render(<App step={1} />);
649 });
650 - await waitForAll(['Suspend! [A1]', 'B', 'C', 'Loading...']);
650 + await waitForAll(['Suspend! [A1]', 'Loading...']);
651
652 // Lots of time elapses before the promise resolves
653 Scheduler.unstable_advanceTime(10000);
packages/react-reconciler/src/__tests__/ReactIncrementalErrorHandling-test.internal.js
+35 -41
@@ -99,17 +99,19 @@ describe('ReactIncrementalErrorHandling', () => {
99
100 React.startTransition(() => {
101 ReactNoop.render(
102 - <ErrorBoundary>
103 - <Indirection>
102 + <>
103 + <ErrorBoundary>
104 <Indirection>
105 <Indirection>
106 - <BadRender />
107 - <Indirection />
108 - <Indirection />
106 + <Indirection>
107 + <BadRender />
108 + </Indirection>
109 </Indirection>
110 </Indirection>
111 - </Indirection>
112 - </ErrorBoundary>,
111 + </ErrorBoundary>
112 + <Indirection />
113 + <Indirection />
114 + </>,
115 );
116 });
117
@@ -121,13 +123,6 @@ describe('ReactIncrementalErrorHandling', () => {
123 'Indirection',
124 // An error is thrown. React keeps rendering asynchronously.
125 'throw',
124 - ]);
125 -
126 - // Still rendering async...
127 - await waitFor(['Indirection']);
128 -
129 - await waitFor([
130 - 'Indirection',
126
127 // Call getDerivedStateFromError and re-render the error boundary, this
128 // time rendering an error message.
@@ -135,14 +130,20 @@ describe('ReactIncrementalErrorHandling', () => {
130 'ErrorBoundary (catch)',
131 'ErrorMessage',
132 ]);
138 -
139 - // Since the error was thrown during an async render, React won't commit
140 - // the result yet.
133 expect(ReactNoop).toMatchRenderedOutput(null);
134
143 - // Instead, it will try rendering one more time, synchronously, in case that
144 - // happens to fix the error.
135 + // The work loop unwound to the nearest error boundary. Continue rendering
136 + // asynchronously.
137 + await waitFor(['Indirection']);
138 +
139 + // Since the error was thrown during an async render, React won't commit the
140 + // result yet. After render we render the last child, React will attempt to
141 + // render again, synchronously, just in case that happens to fix the error
142 + // (i.e. as in the case of a data race). Flush just one more unit of work to
143 + // demonstrate that this render is synchronous.
144 expect(ReactNoop.flushNextYield()).toEqual([
145 + 'Indirection',
146 +
147 'ErrorBoundary (try)',
148 'Indirection',
149 'Indirection',
@@ -151,11 +152,11 @@ describe('ReactIncrementalErrorHandling', () => {
152 // The error was thrown again. This time, React will actually commit
153 // the result.
154 'throw',
154 - 'Indirection',
155 - 'Indirection',
155 'getDerivedStateFromError',
156 'ErrorBoundary (catch)',
157 'ErrorMessage',
158 + 'Indirection',
159 + 'Indirection',
160 ]);
161
162 expect(ReactNoop).toMatchRenderedOutput(
@@ -197,17 +198,19 @@ describe('ReactIncrementalErrorHandling', () => {
198
199 React.startTransition(() => {
200 ReactNoop.render(
200 - <ErrorBoundary>
201 - <Indirection>
201 + <>
202 + <ErrorBoundary>
203 <Indirection>
204 <Indirection>
204 - <BadRender />
205 - <Indirection />
206 - <Indirection />
205 + <Indirection>
206 + <BadRender />
207 + </Indirection>
208 </Indirection>
209 </Indirection>
209 - </Indirection>
210 - </ErrorBoundary>,
210 + </ErrorBoundary>
211 + <Indirection />
212 + <Indirection />
213 + </>,
214 );
215 });
216
@@ -384,12 +387,11 @@ describe('ReactIncrementalErrorHandling', () => {
387 // Render the bad component asynchronously
388 await waitFor(['Parent', 'BadRender']);
389
387 - // Finish the rest of the async work
388 - await waitFor(['Sibling']);
389 -
390 - // Old scheduler renders, commits, and throws synchronously
390 + // The work loop unwound to the nearest error boundary. React will try
391 + // to render one more time, synchronously. Flush just one unit of work to
392 + // demonstrate that this render is synchronous.
393 expect(() => Scheduler.unstable_flushNumberOfYields(1)).toThrow('oops');
392 - assertLog(['Parent', 'BadRender', 'Sibling', 'commit']);
394 + assertLog(['Parent', 'BadRender', 'commit']);
395 expect(ReactNoop).toMatchRenderedOutput(null);
396 });
397
@@ -435,16 +437,12 @@ describe('ReactIncrementalErrorHandling', () => {
437 // The render expired, but we shouldn't throw out the partial work.
438 // Finish the current level.
439 'Oops',
438 - 'C',
439 - 'D',
440
441 // Since the error occurred during a partially concurrent render, we should
442 // retry one more time, synchronously.
443 'A',
444 'B',
445 'Oops',
446 - 'C',
447 - 'D',
446 ]);
447 expect(ReactNoop).toMatchRenderedOutput(null);
448 });
@@ -1571,14 +1569,10 @@ describe('ReactIncrementalErrorHandling', () => {
1569 'ErrorBoundary (try)',
1570 'throw',
1571 // Continue rendering siblings after BadRender throws
1574 - 'BadRenderSibling',
1575 - 'BadRenderSibling',
1572
1573 // React retries one more time
1574 'ErrorBoundary (try)',
1575 'throw',
1580 - 'BadRenderSibling',
1581 - 'BadRenderSibling',
1576
1577 // Errored again on retry. Now handle it.
1578 'componentDidCatch',
packages/react-reconciler/src/__tests__/ReactLazy-test.internal.js
+19 -5
@@ -5,6 +5,7 @@ let Scheduler;
5 let ReactFeatureFlags;
6 let Suspense;
7 let lazy;
8 +let waitFor;
9 let waitForAll;
10 let waitForThrow;
11 let assertLog;
@@ -34,6 +35,7 @@ describe('ReactLazy', () => {
35 Scheduler = require('scheduler');
36
37 const InternalTestUtils = require('internal-test-utils');
38 + waitFor = InternalTestUtils.waitFor;
39 waitForAll = InternalTestUtils.waitForAll;
40 waitForThrow = InternalTestUtils.waitForThrow;
41 assertLog = InternalTestUtils.assertLog;
@@ -256,8 +258,14 @@ describe('ReactLazy', () => {
258 }
259 }
260
259 - const LazyChildA = lazy(() => fakeImport(Child));
260 - const LazyChildB = lazy(() => fakeImport(Child));
261 + const LazyChildA = lazy(() => {
262 + Scheduler.log('Suspend! [LazyChildA]');
263 + return fakeImport(Child);
264 + });
265 + const LazyChildB = lazy(() => {
266 + Scheduler.log('Suspend! [LazyChildB]');
267 + return fakeImport(Child);
268 + });
269
270 function Parent({swap}) {
271 return (
@@ -279,11 +287,16 @@ describe('ReactLazy', () => {
287 unstable_isConcurrent: true,
288 });
289
282 - await waitForAll(['Loading...']);
290 + await waitForAll(['Suspend! [LazyChildA]', 'Loading...']);
291 expect(root).not.toMatchRenderedOutput('AB');
292
293 await resolveFakeImport(Child);
294
295 + // B suspends even though it happens to share the same import as A.
296 + // TODO: React.lazy should implement the `status` and `value` fields, so
297 + // we can unwrap the result synchronously if it already loaded. Like `use`.
298 + await waitFor(['A', 'Suspend! [LazyChildB]']);
299 +
300 await waitForAll(['A', 'B', 'Did mount: A', 'Did mount: B']);
301 expect(root).toMatchRenderedOutput('AB');
302
@@ -1389,12 +1402,13 @@ describe('ReactLazy', () => {
1402 unstable_isConcurrent: true,
1403 });
1404
1392 - await waitForAll(['Init A', 'Init B', 'Loading...']);
1405 + await waitForAll(['Init A', 'Loading...']);
1406 expect(root).not.toMatchRenderedOutput('AB');
1407
1408 await resolveFakeImport(ChildA);
1396 - await resolveFakeImport(ChildB);
1409 + await waitForAll(['A', 'Init B']);
1410
1411 + await resolveFakeImport(ChildB);
1412 await waitForAll(['A', 'B', 'Did mount: A', 'Did mount: B']);
1413 expect(root).toMatchRenderedOutput('AB');
1414
packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js
+3 -21
@@ -134,8 +134,6 @@ describe('ReactSuspense', () => {
134 'Bar',
135 // A suspends
136 'Suspend! [A]',
137 - // But we keep rendering the siblings
138 - 'B',
137 'Loading...',
138 ]);
139 expect(root).toMatchRenderedOutput(null);
@@ -272,13 +270,7 @@ describe('ReactSuspense', () => {
270 unstable_isConcurrent: true,
271 });
272
275 - await waitForAll([
276 - 'Foo',
277 - 'Suspend! [A]',
278 - 'Suspend! [B]',
279 - 'Loading more...',
280 - 'Loading...',
281 - ]);
273 + await waitForAll(['Foo', 'Suspend! [A]', 'Loading...']);
274 expect(root).toMatchRenderedOutput('Loading...');
275
276 await resolveText('A');
@@ -316,13 +308,7 @@ describe('ReactSuspense', () => {
308 unstable_isConcurrent: true,
309 });
310
319 - await waitForAll([
320 - 'Foo',
321 - 'Suspend! [A]',
322 - 'Suspend! [B]',
323 - 'Loading more...',
324 - 'Loading...',
325 - ]);
311 + await waitForAll(['Foo', 'Suspend! [A]', 'Loading...']);
312 expect(root).toMatchRenderedOutput('Loading...');
313
314 await resolveText('A');
@@ -937,11 +923,7 @@ describe('ReactSuspense', () => {
923 unstable_isConcurrent: true,
924 });
925
940 - await waitForAll([
941 - 'Suspend! [Child 1]',
942 - 'Suspend! [Child 2]',
943 - 'Loading...',
944 - ]);
926 + await waitForAll(['Suspend! [Child 1]', 'Loading...']);
927 await resolveText('Child 1');
928 await waitForAll(['Child 1', 'Suspend! [Child 2]']);
929
packages/react-reconciler/src/__tests__/ReactSuspenseCallback-test.js
+1 -1
@@ -126,7 +126,7 @@ describe('ReactSuspense', () => {
126 ReactNoop.render(element);
127 await waitForAll([]);
128 expect(ReactNoop).toMatchRenderedOutput('Waiting Tier 1');
129 - expect(ops).toEqual([new Set([promise1, promise2])]);
129 + expect(ops).toEqual([new Set([promise1])]);
130 ops = [];
131
132 await resolve1();
packages/react-reconciler/src/__tests__/ReactSuspenseEffectsSemantics-test.js
+3 -62
@@ -266,7 +266,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
266 'App render',
267 'Text:Inside:Before render',
268 'Suspend:Async',
269 - 'ClassText:Inside:After render',
269 'Text:Fallback render',
270 'Text:Outside render',
271 'Text:Fallback create layout',
@@ -641,7 +640,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
640 'App render',
641 'Text:Inside:Before render',
642 'Suspend:Async',
644 - 'Text:Inside:After render',
643 'Text:Fallback render',
644 'Text:Outside render',
645 ]);
@@ -796,7 +794,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
794 'App render',
795 'ClassText:Inside:Before render',
796 'Suspend:Async',
799 - 'ClassText:Inside:After render',
797 'ClassText:Fallback render',
798 'ClassText:Outside render',
799 ]);
@@ -917,13 +914,7 @@ describe('ReactSuspenseEffectsSemantics', () => {
914 <AsyncText text="Async" ms={1000} />
915 </App>,
916 );
920 - await waitFor([
921 - 'App render',
922 - 'Suspend:Async',
923 - 'Text:Outer render',
924 - 'Text:Inner render',
925 - 'Text:Fallback render',
926 - ]);
917 + await waitFor(['App render', 'Suspend:Async', 'Text:Fallback render']);
918 expect(ReactNoop).toMatchRenderedOutput(
919 <span prop="Outer">
920 <span prop="Inner" />
@@ -1047,7 +1038,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
1038 await waitFor([
1039 'App render',
1040 'Suspend:Async',
1050 - 'Text:Outer render',
1041 // Text:MemoizedInner is memoized
1042 'Text:Fallback render',
1043 ]);
@@ -1186,9 +1176,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
1176 assertLog([
1177 'Text:Outer render',
1178 'Suspend:OuterAsync_1',
1189 - 'Text:Inner render',
1190 - 'Suspend:InnerAsync_1',
1191 - 'Text:InnerFallback render',
1179 'Text:OuterFallback render',
1180 'Text:Outer destroy layout',
1181 'Text:InnerFallback destroy layout',
@@ -1208,12 +1195,7 @@ describe('ReactSuspenseEffectsSemantics', () => {
1195 await act(async () => {
1196 await resolveText('InnerAsync_1');
1197 });
1211 - assertLog([
1212 - 'Text:Outer render',
1213 - 'Suspend:OuterAsync_1',
1214 - 'Text:Inner render',
1215 - 'AsyncText:InnerAsync_1 render',
1216 - ]);
1198 + assertLog(['Text:Outer render', 'Suspend:OuterAsync_1']);
1199 expect(ReactNoop).toMatchRenderedOutput(
1200 <>
1201 <span prop="Outer" hidden={true} />
@@ -1236,9 +1218,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
1218 assertLog([
1219 'Text:Outer render',
1220 'Suspend:OuterAsync_1',
1239 - 'Text:Inner render',
1240 - 'Suspend:InnerAsync_2',
1241 - 'Text:InnerFallback render',
1221 'Text:OuterFallback render',
1222 ]);
1223 expect(ReactNoop).toMatchRenderedOutput(
@@ -1310,8 +1289,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
1289 assertLog([
1290 'Text:Outer render',
1291 'Suspend:OuterAsync_2',
1313 - 'Text:Inner render',
1314 - 'AsyncText:InnerAsync_2 render',
1292 'Text:OuterFallback render',
1293 'Text:Outer destroy layout',
1294 'AsyncText:OuterAsync_1 destroy layout',
@@ -1426,9 +1403,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
1403 assertLog([
1404 'Text:Outer render',
1405 'Suspend:OuterAsync_1',
1429 - 'Text:Inner render',
1430 - 'Suspend:InnerAsync_1',
1431 - 'Text:InnerFallback render',
1406 'Text:OuterFallback render',
1407 'Text:Outer destroy layout',
1408 'Text:InnerFallback destroy layout',
@@ -1922,8 +1896,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
1896 'ErrorBoundary render: try',
1897 'App render',
1898 'Suspend:Async',
1925 - 'ThrowsInDidMount render',
1926 - 'Text:Inside render',
1899 'Text:Fallback render',
1900 'Text:Outside render',
1901 'ThrowsInDidMount componentWillUnmount',
@@ -2058,8 +2030,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
2030 'ErrorBoundary render: try',
2031 'App render',
2032 'Suspend:Async',
2061 - 'ThrowsInWillUnmount render',
2062 - 'Text:Inside render',
2033 'Text:Fallback render',
2034 'Text:Outside render',
2035
@@ -2170,8 +2140,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
2140 'ErrorBoundary render: try',
2141 'App render',
2142 'Suspend:Async',
2173 - 'ThrowsInLayoutEffect render',
2174 - 'Text:Inside render',
2143 'Text:Fallback render',
2144 'Text:Outside render',
2145 'ThrowsInLayoutEffect useLayoutEffect destroy',
@@ -2307,8 +2275,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
2275 'ErrorBoundary render: try',
2276 'App render',
2277 'Suspend:Async',
2310 - 'ThrowsInLayoutEffectDestroy render',
2311 - 'Text:Inside render',
2278 'Text:Fallback render',
2279 'Text:Outside render',
2280
@@ -2399,8 +2365,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
2365 await waitFor([
2366 'Text:Function render',
2367 'Suspend:Async_1',
2402 - 'Suspend:Async_2',
2403 - 'ClassText:Class render',
2368 'ClassText:Fallback render',
2369 ]);
2370 expect(ReactNoop).toMatchRenderedOutput(
@@ -2435,7 +2399,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
2399 'Text:Function render',
2400 'AsyncText:Async_1 render',
2401 'Suspend:Async_2',
2438 - 'ClassText:Class render',
2402 ]);
2403 expect(ReactNoop).toMatchRenderedOutput(
2404 <>
@@ -2554,7 +2517,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
2517 'Text:Function render',
2518 'Suspender "A" render',
2519 'Suspend:A',
2557 - 'ClassText:Class render',
2520 'ClassText:Fallback render',
2521 ]);
2522 expect(ReactNoop).toMatchRenderedOutput(
@@ -2588,12 +2550,7 @@ describe('ReactSuspenseEffectsSemantics', () => {
2550 await act(async () => {
2551 await resolveText('A');
2552 });
2591 - assertLog([
2592 - 'Text:Function render',
2593 - 'Suspender "B" render',
2594 - 'Suspend:B',
2595 - 'ClassText:Class render',
2596 - ]);
2553 + assertLog(['Text:Function render', 'Suspender "B" render', 'Suspend:B']);
2554 expect(ReactNoop).toMatchRenderedOutput(
2555 <>
2556 <span prop="Function" hidden={true} />
@@ -2819,9 +2776,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
2776 assertLog([
2777 'App render',
2778 'Suspend:Async',
2822 - 'RefCheckerOuter render',
2823 - 'RefCheckerInner:refObject render',
2824 - 'RefCheckerInner:refCallback render',
2779 'Text:Fallback render',
2780 'RefCheckerOuter destroy layout refObject? true refCallback? true',
2781 'RefCheckerInner:refObject destroy layout ref? false',
@@ -2925,11 +2879,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
2879 assertLog([
2880 'App render',
2881 'Suspend:Async',
2928 - 'RefCheckerOuter render',
2929 - 'ClassComponent:refObject render',
2930 - 'RefCheckerInner:refObject render',
2931 - 'ClassComponent:refCallback render',
2932 - 'RefCheckerInner:refCallback render',
2882 'Text:Fallback render',
2883 'RefCheckerOuter destroy layout refObject? true refCallback? true',
2884 'RefCheckerInner:refObject destroy layout ref? false',
@@ -3029,11 +2978,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
2978 assertLog([
2979 'App render',
2980 'Suspend:Async',
3032 - 'RefCheckerOuter render',
3033 - 'FunctionComponent render',
3034 - 'RefCheckerInner:refObject render',
3035 - 'FunctionComponent render',
3036 - 'RefCheckerInner:refCallback render',
2981 'Text:Fallback render',
2982 'RefCheckerOuter destroy layout refObject? true refCallback? true',
2983 'RefCheckerInner:refObject destroy layout ref? false',
@@ -3138,7 +3082,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
3082 assertLog([
3083 'App render',
3084 'Suspend:Async',
3141 - 'RefChecker render',
3085 'Text:Fallback render',
3086 'RefChecker destroy layout ref? true',
3087 'Text:Fallback create layout',
@@ -3252,8 +3195,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
3195 'ErrorBoundary render: try',
3196 'App render',
3197 'Suspend:Async',
3255 - 'ThrowsInRefCallback render',
3256 - 'Text:Inside render',
3198 'Text:Fallback render',
3199 'Text:Outside render',
3200 'ThrowsInRefCallback refCallback ref? false',
packages/react-reconciler/src/__tests__/ReactSuspensePlaceholder-test.internal.js
+35 -14
@@ -134,7 +134,7 @@ describe('ReactSuspensePlaceholder', () => {
134 // Initial mount
135 ReactNoop.render(<App middleText="B" />);
136
137 - await waitForAll(['A', 'Suspend! [B]', 'C', 'Loading...']);
137 + await waitForAll(['A', 'Suspend! [B]', 'Loading...']);
138 expect(ReactNoop).toMatchRenderedOutput('Loading...');
139
140 jest.advanceTimersByTime(1000);
@@ -152,7 +152,7 @@ describe('ReactSuspensePlaceholder', () => {
152
153 // Update
154 ReactNoop.render(<App middleText="B2" />);
155 - await waitForAll(['Suspend! [B2]', 'C', 'Loading...']);
155 + await waitForAll(['Suspend! [B2]', 'Loading...']);
156
157 // Time out the update
158 jest.advanceTimersByTime(750);
@@ -196,7 +196,7 @@ describe('ReactSuspensePlaceholder', () => {
196 // Initial mount
197 ReactNoop.render(<App middleText="B" />);
198
199 - await waitForAll(['A', 'Suspend! [B]', 'C', 'Loading...']);
199 + await waitForAll(['A', 'Suspend! [B]', 'Loading...']);
200
201 expect(ReactNoop).not.toMatchRenderedOutput('ABC');
202
@@ -207,7 +207,7 @@ describe('ReactSuspensePlaceholder', () => {
207
208 // Update
209 ReactNoop.render(<App middleText="B2" />);
210 - await waitForAll(['A', 'Suspend! [B2]', 'C', 'Loading...']);
210 + await waitForAll(['A', 'Suspend! [B2]', 'Loading...']);
211 // Time out the update
212 jest.advanceTimersByTime(750);
213 await waitForAll([]);
@@ -241,7 +241,7 @@ describe('ReactSuspensePlaceholder', () => {
241 // Initial mount
242 ReactNoop.render(<App middleText="b" />);
243
244 - await waitForAll(['a', 'Suspend! [b]', 'c', 'Loading...']);
244 + await waitForAll(['a', 'Suspend! [b]', 'Loading...']);
245
246 expect(ReactNoop).toMatchRenderedOutput(<uppercase>LOADING...</uppercase>);
247
@@ -252,7 +252,7 @@ describe('ReactSuspensePlaceholder', () => {
252
253 // Update
254 ReactNoop.render(<App middleText="b2" />);
255 - await waitForAll(['a', 'Suspend! [b2]', 'c', 'Loading...']);
255 + await waitForAll(['a', 'Suspend! [b2]', 'Loading...']);
256 // Time out the update
257 jest.advanceTimersByTime(750);
258 await waitForAll([]);
@@ -344,7 +344,6 @@ describe('ReactSuspensePlaceholder', () => {
344 'App',
345 'Suspending',
346 'Suspend! [Loaded]',
347 - 'Text',
347 'Fallback',
348 ]);
349 // Since this is initial render we immediately commit the fallback. Another test below
@@ -354,8 +353,8 @@ describe('ReactSuspensePlaceholder', () => {
353
354 // Initial mount only shows the "Loading..." Fallback.
355 // The treeBaseDuration then should be 10ms spent rendering Fallback,
357 - // but the actualDuration should also include the 8ms spent rendering the hidden tree.
358 - expect(onRender.mock.calls[0][2]).toBe(18);
356 + // but the actualDuration should also include the 3ms spent rendering the hidden tree.
357 + expect(onRender.mock.calls[0][2]).toBe(13);
358 expect(onRender.mock.calls[0][3]).toBe(10);
359
360 // Resolve the pending promise.
@@ -437,6 +436,30 @@ describe('ReactSuspensePlaceholder', () => {
436 });
437
438 it('properly accounts for base durations when a suspended times out in a concurrent tree', async () => {
439 + const Fallback = () => {
440 + Scheduler.log('Fallback');
441 + Scheduler.unstable_advanceTime(10);
442 + return 'Loading...';
443 + };
444 +
445 + const Suspending = () => {
446 + Scheduler.log('Suspending');
447 + Scheduler.unstable_advanceTime(2);
448 + return <AsyncText ms={1000} text="Loaded" fakeRenderDuration={1} />;
449 + };
450 +
451 + App = ({shouldSuspend, text = 'Text', textRenderDuration = 5}) => {
452 + Scheduler.log('App');
453 + return (
454 + <Profiler id="root" onRender={onRender}>
455 + <Suspense fallback={<Fallback />}>
456 + {shouldSuspend && <Suspending />}
457 + <Text fakeRenderDuration={textRenderDuration} text={text} />
458 + </Suspense>
459 + </Profiler>
460 + );
461 + };
462 +
463 ReactNoop.render(
464 <>
465 <App shouldSuspend={false} textRenderDuration={5} />
@@ -463,7 +486,6 @@ describe('ReactSuspensePlaceholder', () => {
486 'App',
487 'Suspending',
488 'Suspend! [Loaded]',
466 - 'Text',
489 'Fallback',
490 ]);
491 expect(ReactNoop).toMatchRenderedOutput('Text');
@@ -475,9 +497,9 @@ describe('ReactSuspensePlaceholder', () => {
497
498 // The suspense update should only show the "Loading..." Fallback.
499 // The actual duration should include 10ms spent rendering Fallback,
478 - // plus the 8ms render all of the hidden, suspended subtree.
500 + // plus the 3ms render all of the partially rendered suspended subtree.
501 // But the tree base duration should only include 10ms spent rendering Fallback.
480 - expect(onRender.mock.calls[1][2]).toBe(18);
502 + expect(onRender.mock.calls[1][2]).toBe(13);
503 expect(onRender.mock.calls[1][3]).toBe(10);
504
505 // Update again while timed out.
@@ -497,13 +519,12 @@ describe('ReactSuspensePlaceholder', () => {
519 // consequence of AsyncText relying on the same timer queue as React's
520 // internal Suspense timer. We should decouple our AsyncText helpers
521 // from timers.
500 - Scheduler.unstable_advanceTime(100);
522 + Scheduler.unstable_advanceTime(200);
523
524 await waitForAll([
525 'App',
526 'Suspending',
527 'Suspend! [Loaded]',
506 - 'New',
528 'Fallback',
529 'Suspend! [Sibling]',
530 ]);
packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js
+15 -27
@@ -224,11 +224,11 @@ describe('ReactSuspenseWithNoopRenderer', () => {
224 'Bar',
225 // A suspends
226 'Suspend! [A]',
227 - // But we keep rendering the siblings
228 - 'B',
227 + // We immediately unwind and switch to a fallback without
228 + // rendering siblings.
229 'Loading...',
230 'C',
231 - // We leave D incomplete.
231 + // Yield before rendering D
232 ]);
233 expect(ReactNoop).toMatchRenderedOutput(null);
234
@@ -293,8 +293,8 @@ describe('ReactSuspenseWithNoopRenderer', () => {
293 'Bar',
294 // A suspends
295 'Suspend! [A]',
296 - // But we keep rendering the siblings
297 - 'B',
296 + // We immediately unwind and switch to a fallback without
297 + // rendering siblings.
298 'Loading...',
299 ]);
300 expect(ReactNoop).toMatchRenderedOutput(null);
@@ -367,7 +367,6 @@ describe('ReactSuspenseWithNoopRenderer', () => {
367 // A shell is needed. The update cause it to suspend.
368 ReactNoop.render(<Suspense fallback={<Text text="Loading..." />} />);
369 await waitForAll([]);
370 - // B suspends. Continue rendering the remaining siblings.
370 React.startTransition(() => {
371 ReactNoop.render(
372 <Suspense fallback={<Text text="Loading..." />}>
@@ -378,8 +377,8 @@ describe('ReactSuspenseWithNoopRenderer', () => {
377 </Suspense>,
378 );
379 });
381 - // B suspends. Continue rendering the remaining siblings.
382 - await waitForAll(['A', 'Suspend! [B]', 'C', 'D', 'Loading...']);
380 + // B suspends. Render a fallback
381 + await waitForAll(['A', 'Suspend! [B]', 'Loading...']);
382 // Did not commit yet.
383 expect(ReactNoop).toMatchRenderedOutput(null);
384
@@ -594,7 +593,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
593 React.startTransition(() => {
594 ReactNoop.render(<App showA={true} showB={true} />);
595 });
597 - await waitForAll(['Suspend! [A]', 'B', 'Loading...']);
596 + await waitForAll(['Suspend! [A]', 'Loading...']);
597 expect(ReactNoop).toMatchRenderedOutput(null);
598
599 await resolveText('A');
@@ -778,8 +777,6 @@ describe('ReactSuspenseWithNoopRenderer', () => {
777 'Sync',
778 // The async content suspends
779 'Suspend! [Outer content]',
781 - 'Suspend! [Inner content]',
782 - 'Loading inner...',
780 'Loading outer...',
781 ]);
782 // The outer loading state finishes immediately.
@@ -888,7 +885,6 @@ describe('ReactSuspenseWithNoopRenderer', () => {
885 assertLog([
886 'Suspend! [Async]',
887 'Suspend! [Loading (inner)...]',
891 - 'Sync',
888 'Loading (outer)...',
889 ]);
890 // The tree commits synchronously
@@ -1018,7 +1014,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
1014 </>,
1015 );
1016 });
1021 - await waitFor(['Suspend! [Async]', 'Sibling']);
1017 + await waitFor(['Suspend! [Async]']);
1018
1019 await resolveText('Async');
1020
@@ -1059,7 +1055,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
1055 <AsyncText text="B" />
1056 </Suspense>,
1057 );
1062 - await waitForAll(['Suspend! [A]', 'Suspend! [B]', 'Loading...']);
1058 + await waitForAll(['Suspend! [A]', 'Loading...']);
1059 expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
1060
1061 await resolveText('A');
@@ -1211,7 +1207,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
1207 }
1208
1209 ReactNoop.render(<App />);
1214 - await waitForAll(['Suspend! [A]', 'Suspend! [B]', 'Suspend! [C]']);
1210 + await waitForAll(['Suspend! [A]']);
1211 expect(ReactNoop).toMatchRenderedOutput('Loading...');
1212
1213 await resolveText('A');
@@ -1928,9 +1924,6 @@ describe('ReactSuspenseWithNoopRenderer', () => {
1924 'Foo',
1925 // A suspends
1926 'Suspend! [A]',
1931 - // B suspends
1932 - 'Suspend! [B]',
1933 - 'Loading more...',
1927 'Loading...',
1928 ]);
1929 expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
@@ -1943,7 +1936,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
1936 // Retry with the new content.
1937 await waitForAll([
1938 'A',
1946 - // B still suspends
1939 + // B suspends
1940 'Suspend! [B]',
1941 'Loading more...',
1942 ]);
@@ -1989,9 +1982,6 @@ describe('ReactSuspenseWithNoopRenderer', () => {
1982 'Foo',
1983 // A suspends
1984 'Suspend! [A]',
1992 - // B suspends
1993 - 'Suspend! [B]',
1994 - 'Loading more...',
1985 'Loading...',
1986 ]);
1987 expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
@@ -2001,7 +1991,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
1991 // Retry with the new content.
1992 await waitForAll([
1993 'A',
2004 - // B still suspends
1994 + // B suspends
1995 'Suspend! [B]',
1996 'Loading more...',
1997 ]);
@@ -2148,7 +2138,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
2138 ReactNoop.flushSync(() => showB());
2139 });
2140
2151 - assertLog(['Suspend! [A]', 'Suspend! [B]']);
2141 + assertLog(['Suspend! [A]']);
2142 });
2143
2144 // TODO: flip to "warns" when this is implemented again.
@@ -2254,7 +2244,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
2244 }
2245
2246 ReactNoop.render(<Foo />);
2257 - await waitForAll(['Foo', 'Suspend! [A]', 'B', 'Initial load...']);
2247 + await waitForAll(['Foo', 'Suspend! [A]', 'Initial load...']);
2248 expect(ReactNoop).toMatchRenderedOutput(<span prop="Initial load..." />);
2249
2250 // Eventually we resolve and show the data.
@@ -3638,7 +3628,6 @@ describe('ReactSuspenseWithNoopRenderer', () => {
3628 'Outer text: B',
3629 'Outer step: 0',
3630 'Suspend! [Inner text: B]',
3641 - 'Inner step: 0',
3631 'Loading...',
3632 ]);
3633 // Commit the placeholder
@@ -3667,7 +3656,6 @@ describe('ReactSuspenseWithNoopRenderer', () => {
3656 'Outer text: B',
3657 'Outer step: 1',
3658 'Suspend! [Inner text: B]',
3670 - 'Inner step: 1',
3659 'Loading...',
3660 ]);
3661 expect(root).toMatchRenderedOutput(
packages/react-reconciler/src/__tests__/ReactTransition-test.js
-1
@@ -860,7 +860,6 @@ describe('ReactTransition', () => {
860 assertLog([
861 // Suspend.
862 'Suspend! [Async]',
863 - 'Normal pri: 0',
863 'Loading...',
864 ]);
865 expect(root).toMatchRenderedOutput('(empty), Normal pri: 0');
packages/react-reconciler/src/__tests__/ReactTransitionTracing-test.js
-16
@@ -752,10 +752,6 @@ describe('ReactInteractionTracing', () => {
752
753 await waitForAll([
754 'Suspend [Page Two]',
755 - 'Suspend [Show Text One]',
756 - 'Show Text One Loading...',
757 - 'Suspend [Show Text Two]',
758 - 'Show Text Two Loading...',
755 'Loading...',
756 'onTransitionStart(page transition, 1000)',
757 'onTransitionProgress(page transition, 1000, 2000, [suspense page])',
@@ -882,10 +878,6 @@ describe('ReactInteractionTracing', () => {
878
879 await waitForAll([
880 'Suspend [Page Two]',
885 - 'Suspend [Show Text One]',
886 - 'Show Text One Loading...',
887 - 'Suspend [Show Text]',
888 - 'Show Text Loading...',
881 'Loading...',
882 'onTransitionStart(navigate, 1000)',
883 'onTransitionStart(show text one, 1000)',
@@ -1121,8 +1113,6 @@ describe('ReactInteractionTracing', () => {
1113
1114 await waitForAll([
1115 'Suspend [Page Two]',
1124 - 'Suspend [Marker Text]',
1125 - 'Loading...',
1116 'Loading...',
1117 'onTransitionStart(page transition, 1000)',
1118 ]);
@@ -1239,10 +1229,6 @@ describe('ReactInteractionTracing', () => {
1229
1230 await waitForAll([
1231 'Suspend [Outer Text]',
1242 - 'Suspend [Inner Text One]',
1243 - 'Inner One...',
1244 - 'Suspend [Inner Text Two]',
1245 - 'Inner Two...',
1232 'Outer...',
1233 'onTransitionStart(page transition, 1000)',
1234 'onMarkerProgress(page transition, outer marker, 1000, 2000, [outer])',
@@ -1782,8 +1768,6 @@ describe('ReactInteractionTracing', () => {
1768 await advanceTimers(1000);
1769 await waitForAll([
1770 'Suspend [Page One]',
1785 - 'Suspend [Child]',
1786 - 'Loading Child...',
1771 'Loading One...',
1772 'Suspend [Page Two]',
1773 'Loading Two...',
packages/react-reconciler/src/__tests__/ReactUse-test.js
+5 -21
@@ -352,7 +352,7 @@ describe('ReactUse', () => {
352 root.render(<App />);
353 });
354 });
355 - assertLog(['CD', 'Loading...']);
355 + assertLog(['Loading...']);
356 expect(root).toMatchRenderedOutput('Loading...');
357 });
358
@@ -1034,26 +1034,19 @@ describe('ReactUse', () => {
1034 </Suspense>,
1035 );
1036 });
1037 - assertLog([
1038 - 'Async text requested [A]',
1039 - 'Async text requested [B]',
1040 - 'Async text requested [C]',
1041 - '(Loading C...)',
1042 - '(Loading B...)',
1043 - '(Loading A...)',
1044 - ]);
1037 + assertLog(['Async text requested [A]', '(Loading A...)']);
1038 expect(root).toMatchRenderedOutput('(Loading A...)');
1039
1040 await act(() => {
1041 resolveTextRequests('A');
1042 });
1050 - assertLog(['A', '(Loading C...)', '(Loading B...)']);
1043 + assertLog(['A', 'Async text requested [B]', '(Loading B...)']);
1044 expect(root).toMatchRenderedOutput('A(Loading B...)');
1045
1046 await act(() => {
1047 resolveTextRequests('B');
1048 });
1056 - assertLog(['B', '(Loading C...)']);
1049 + assertLog(['B', 'Async text requested [C]', '(Loading C...)']);
1050 expect(root).toMatchRenderedOutput('AB(Loading C...)');
1051
1052 await act(() => {
@@ -1087,14 +1080,7 @@ describe('ReactUse', () => {
1080 </Suspense>,
1081 );
1082 });
1090 - assertLog([
1091 - 'Async text requested [A]',
1092 - 'Async text requested [B]',
1093 - 'Async text requested [C]',
1094 - '(Loading C...)',
1095 - '(Loading B...)',
1096 - '(Loading A...)',
1097 - ]);
1083 + assertLog(['Async text requested [A]', '(Loading A...)']);
1084 expect(root).toMatchRenderedOutput('(Loading A...)');
1085
1086 await act(() => {
@@ -1116,8 +1102,6 @@ describe('ReactUse', () => {
1102 // React does not suspend on the inner requests, because that would
1103 // block A from appearing. Instead it shows a fallback.
1104 'Async text requested [B]',
1119 - 'Async text requested [C]',
1120 - '(Loading C...)',
1105 '(Loading B...)',
1106 ]);
1107 expect(root).toMatchRenderedOutput('A(Loading B...)');
packages/react-reconciler/src/__tests__/useEffectEvent-test.js
+1 -4
@@ -274,10 +274,7 @@ describe('useEffectEvent', () => {
274 await waitForThrow(
275 "A function wrapped in useEffectEvent can't be called during rendering.",
276 );
277 -
278 - // If something throws, we try one more time synchronously in case the error was
279 - // caused by a data race. See recoverFromConcurrentError
280 - assertLog(['Count: 0', 'Count: 0']);
277 + assertLog([]);
278 });
279
280 // @gate enableUseEffectEventHook
packages/shared/ReactFeatureFlags.js
+12
@@ -16,6 +16,18 @@
16 export const enableComponentStackLocations = true;
17 export const disableSchedulerTimeoutBasedOnReactExpirationTime = false;
18
19 +// -----------------------------------------------------------------------------
20 +// Killswitch
21 +//
22 +// Flags that exist solely to turn off a change in case it causes a regression
23 +// when it rolls out to prod. We should remove these as soon as possible.
24 +// -----------------------------------------------------------------------------
25 +
26 +// This is phrased as a negative so that if someone forgets to add a GK, the
27 +// default is to enable the feature. It should only be overridden if there's
28 +// a regression in prod.
29 +export const revertRemovalOfSiblingPrerendering = false;
30 +
31 // -----------------------------------------------------------------------------
32 // Land or remove (moderate effort)
33 //
packages/shared/forks/ReactFeatureFlags.native-fb.js
+1
@@ -43,6 +43,7 @@ export const enableCreateEventHandleAPI = false;
43 export const enableSuspenseCallback = false;
44 export const disableLegacyContext = false;
45 export const disableSchedulerTimeoutBasedOnReactExpirationTime = false;
46 +export const revertRemovalOfSiblingPrerendering = false;
47 export const enableTrustedTypesIntegration = false;
48 export const disableTextareaChildren = false;
49 export const disableModulePatternComponents = false;
packages/shared/forks/ReactFeatureFlags.native-oss.js
+1
@@ -33,6 +33,7 @@ export const enableCreateEventHandleAPI = false;
33 export const enableSuspenseCallback = false;
34 export const disableLegacyContext = false;
35 export const disableSchedulerTimeoutBasedOnReactExpirationTime = false;
36 +export const revertRemovalOfSiblingPrerendering = false;
37 export const enableTrustedTypesIntegration = false;
38 export const disableTextareaChildren = false;
39 export const disableModulePatternComponents = false;
packages/shared/forks/ReactFeatureFlags.test-renderer.js
+1
@@ -33,6 +33,7 @@ export const enableCreateEventHandleAPI = false;
33 export const enableSuspenseCallback = false;
34 export const disableLegacyContext = false;
35 export const disableSchedulerTimeoutBasedOnReactExpirationTime = false;
36 +export const revertRemovalOfSiblingPrerendering = false;
37 export const enableTrustedTypesIntegration = false;
38 export const disableTextareaChildren = false;
39 export const disableModulePatternComponents = false;
packages/shared/forks/ReactFeatureFlags.test-renderer.native.js
+1
@@ -33,6 +33,7 @@ export const enableCreateEventHandleAPI = false;
33 export const enableSuspenseCallback = false;
34 export const disableLegacyContext = false;
35 export const disableSchedulerTimeoutBasedOnReactExpirationTime = false;
36 +export const revertRemovalOfSiblingPrerendering = false;
37 export const enableTrustedTypesIntegration = false;
38 export const disableTextareaChildren = false;
39 export const disableModulePatternComponents = false;
packages/shared/forks/ReactFeatureFlags.test-renderer.www.js
+1
@@ -33,6 +33,7 @@ export const enableCreateEventHandleAPI = false;
33 export const enableSuspenseCallback = true;
34 export const disableLegacyContext = false;
35 export const disableSchedulerTimeoutBasedOnReactExpirationTime = false;
36 +export const revertRemovalOfSiblingPrerendering = false;
37 export const enableTrustedTypesIntegration = false;
38 export const disableTextareaChildren = false;
39 export const disableModulePatternComponents = true;
packages/shared/forks/ReactFeatureFlags.www-dynamic.js
+6
@@ -49,6 +49,12 @@ export const deferRenderPhaseUpdateToNextBatch = !__VARIANT__;
49 // so we don't need to use __VARIANT__ to get extra coverage.
50 export const replayFailedUnitOfWorkWithInvokeGuardedCallback = __DEV__;
51
52 +// This flag only exists so it can be connected to a www GK that acts as a
53 +// killswitch. We don't run our tests against the `true` value because 1) it
54 +// affects too many tests 2) it shouldn't break anything. But it is mildly
55 +// risky, hence this extra precaution.
56 +export const revertRemovalOfSiblingPrerendering = false;
57 +
58 // TODO: These flags are hard-coded to the default values used in open source.
59 // Update the tests so that they pass in either mode, then set these
60 // to __VARIANT__.
packages/shared/forks/ReactFeatureFlags.www.js
+1
@@ -19,6 +19,7 @@ export const {
19 disableIEWorkarounds,
20 enableTrustedTypesIntegration,
21 disableSchedulerTimeoutBasedOnReactExpirationTime,
22 + revertRemovalOfSiblingPrerendering,
23 replayFailedUnitOfWorkWithInvokeGuardedCallback,
24 enableLegacyFBSupport,
25 deferRenderPhaseUpdateToNextBatch,