@samitouri / QOS-React-2 / commits / 9d67847f7b

[Bugfix] Dropped updates inside a suspended tree (#18384)

* Minor test refactor: `resolveText` Adds a `resolveText` method as an alternative to using timers. Also removes dependency on react-cache (for just this one test file; can do the others later). Timer option is still there if you provide a `ms` prop. * Bugfix: Dropped updates in suspended tree When there are multiple updates at different priority levels inside a suspended subtree, all but the highest priority one is dropped after the highest one suspends. We do have tests that cover this for updates that originate outside of the Suspense boundary, but not for updates that originate inside. I'm surprised it's taken us this long to find this issue, but it makes sense in that transition updates usually originate outside the boundary or "seam" of the part of the UI that is transitioning. * Bugfix: Suspense fragment skipped by setState Fixes a bug where updates inside a suspended tree are dropped because the fragment fiber we insert to wrap the hidden children is not part of the return path, so it doesn't get marked during setState. As a workaround, I recompute `childExpirationTime` right before deciding to bail out by bubbling it up from the next level of children. This is something we should consider addressing when we refactor the Fiber data structure. * Add back `lastPendingTime` field This reverts commit 9a541139dfe36e8b9b02b1c6585889e2abf97389. I want to use this so we can check if there might be any lower priority updates in a suspended tree. We can remove it again during the expiration times refactor. * Use `lastPendingTime` instead of Idle We don't currently have an mechanism to check if there are lower priority updates in a subtree, but we can check if there are any in the whole root. This still isn't perfect but it's better than using Idle, which frequently leads to redundant re-renders. When we refactor `expirationTime` to be a bitmask, this will no longer be necessary because we'll know exactly which "task bits" remain. * Add a test for updating the fallback

Andrew Clark committed Mar 26, 2020 at 11:31 UTC 9d67847f7b41e408016cfa16450323b3fee8bc56
4 files changed +473 -53
packages/react-reconciler/src/ReactFiberBeginWork.js
+89 -3
@@ -179,6 +179,7 @@ import {
179 scheduleUpdateOnFiber,
180 renderDidSuspendDelayIfPossible,
181 markUnprocessedUpdateTime,
182 + getWorkInProgressRoot,
183 } from './ReactFiberWorkLoop';
184
185 const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner;
@@ -1590,6 +1591,35 @@ function shouldRemainOnFallback(
1591 );
1592 }
1593
1594 +function getRemainingWorkInPrimaryTree(
1595 + workInProgress,
1596 + currentChildExpirationTime,
1597 + renderExpirationTime,
1598 +) {
1599 + if (currentChildExpirationTime < renderExpirationTime) {
1600 + // The highest priority remaining work is not part of this render. So the
1601 + // remaining work has not changed.
1602 + return currentChildExpirationTime;
1603 + }
1604 + if ((workInProgress.mode & BlockingMode) !== NoMode) {
1605 + // The highest priority remaining work is part of this render. Since we only
1606 + // keep track of the highest level, we don't know if there's a lower
1607 + // priority level scheduled. As a compromise, we'll render at the lowest
1608 + // known level in the entire tree, since that will include everything.
1609 + // TODO: If expirationTime were a bitmask where each bit represents a
1610 + // separate task thread, this would be: currentChildBits & ~renderBits
1611 + const root = getWorkInProgressRoot();
1612 + if (root !== null) {
1613 + const lastPendingTime = root.lastPendingTime;
1614 + if (lastPendingTime < renderExpirationTime) {
1615 + return lastPendingTime;
1616 + }
1617 + }
1618 + }
1619 + // In legacy mode, there's no work left.
1620 + return NoWork;
1621 +}
1622 +
1623 function updateSuspenseComponent(
1624 current,
1625 workInProgress,
@@ -1831,8 +1861,15 @@ function updateSuspenseComponent(
1861 fallbackChildFragment.return = workInProgress;
1862 primaryChildFragment.sibling = fallbackChildFragment;
1863 fallbackChildFragment.effectTag |= Placement;
1834 - primaryChildFragment.childExpirationTime = NoWork;
1835 -
1864 + primaryChildFragment.childExpirationTime = getRemainingWorkInPrimaryTree(
1865 + workInProgress,
1866 + // This argument represents the remaining work in the current
1867 + // primary tree. Since the current tree did not already time out
1868 + // the direct parent of the primary children is the Suspense
1869 + // fiber, not a fragment.
1870 + current.childExpirationTime,
1871 + renderExpirationTime,
1872 + );
1873 workInProgress.memoizedState = SUSPENDED_MARKER;
1874 workInProgress.child = primaryChildFragment;
1875
@@ -1895,6 +1932,11 @@ function updateSuspenseComponent(
1932 );
1933 fallbackChildFragment.return = workInProgress;
1934 primaryChildFragment.sibling = fallbackChildFragment;
1935 + primaryChildFragment.childExpirationTime = getRemainingWorkInPrimaryTree(
1936 + workInProgress,
1937 + currentPrimaryChildFragment.childExpirationTime,
1938 + renderExpirationTime,
1939 + );
1940 primaryChildFragment.childExpirationTime = NoWork;
1941 // Skip the primary children, and continue working on the
1942 // fallback children.
@@ -1989,7 +2031,15 @@ function updateSuspenseComponent(
2031 fallbackChildFragment.return = workInProgress;
2032 primaryChildFragment.sibling = fallbackChildFragment;
2033 fallbackChildFragment.effectTag |= Placement;
1992 - primaryChildFragment.childExpirationTime = NoWork;
2034 + primaryChildFragment.childExpirationTime = getRemainingWorkInPrimaryTree(
2035 + workInProgress,
2036 + // This argument represents the remaining work in the current
2037 + // primary tree. Since the current tree did not already time out
2038 + // the direct parent of the primary children is the Suspense
2039 + // fiber, not a fragment.
2040 + current.childExpirationTime,
2041 + renderExpirationTime,
2042 + );
2043 // Skip the primary children, and continue working on the
2044 // fallback children.
2045 workInProgress.memoizedState = SUSPENDED_MARKER;
@@ -3006,6 +3056,42 @@ function beginWork(
3056 renderExpirationTime,
3057 );
3058 } else {
3059 + // The primary child fragment does not have pending work marked
3060 + // on it...
3061 +
3062 + // ...usually. There's an unfortunate edge case where the fragment
3063 + // fiber is not part of the return path of the children, so when
3064 + // an update happens, the fragment doesn't get marked during
3065 + // setState. This is something we should consider addressing when
3066 + // we refactor the Fiber data structure. (There's a test with more
3067 + // details; to find it, comment out the following block and see
3068 + // which one fails.)
3069 + //
3070 + // As a workaround, we need to recompute the `childExpirationTime`
3071 + // by bubbling it up from the next level of children. This is
3072 + // based on similar logic in `resetChildExpirationTime`.
3073 + let primaryChild = primaryChildFragment.child;
3074 + while (primaryChild !== null) {
3075 + const childUpdateExpirationTime = primaryChild.expirationTime;
3076 + const childChildExpirationTime =
3077 + primaryChild.childExpirationTime;
3078 + if (
3079 + (childUpdateExpirationTime !== NoWork &&
3080 + childUpdateExpirationTime >= renderExpirationTime) ||
3081 + (childChildExpirationTime !== NoWork &&
3082 + childChildExpirationTime >= renderExpirationTime)
3083 + ) {
3084 + // Found a child with an update with sufficient priority.
3085 + // Use the normal path to render the primary children again.
3086 + return updateSuspenseComponent(
3087 + current,
3088 + workInProgress,
3089 + renderExpirationTime,
3090 + );
3091 + }
3092 + primaryChild = primaryChild.sibling;
3093 + }
3094 +
3095 pushSuspenseContext(
3096 workInProgress,
3097 setDefaultShallowSuspenseContext(suspenseStackCursor.current),
packages/react-reconciler/src/ReactFiberRoot.js
+12
@@ -65,6 +65,8 @@ type BaseFiberRootProperties = {|
65 callbackPriority: ReactPriorityLevel,
66 // The earliest pending expiration time that exists in the tree
67 firstPendingTime: ExpirationTime,
68 + // The latest pending expiration time that exists in the tree
69 + lastPendingTime: ExpirationTime,
70 // The earliest suspended expiration time that exists in the tree
71 firstSuspendedTime: ExpirationTime,
72 // The latest suspended expiration time that exists in the tree
@@ -122,6 +124,7 @@ function FiberRootNode(containerInfo, tag, hydrate) {
124 this.callbackNode = null;
125 this.callbackPriority = NoPriority;
126 this.firstPendingTime = NoWork;
127 + this.lastPendingTime = NoWork;
128 this.firstSuspendedTime = NoWork;
129 this.lastSuspendedTime = NoWork;
130 this.nextKnownPendingLevel = NoWork;
@@ -205,6 +208,10 @@ export function markRootUpdatedAtTime(
208 if (expirationTime > firstPendingTime) {
209 root.firstPendingTime = expirationTime;
210 }
211 + const lastPendingTime = root.lastPendingTime;
212 + if (lastPendingTime === NoWork || expirationTime < lastPendingTime) {
213 + root.lastPendingTime = expirationTime;
214 + }
215
216 // Update the range of suspended times. Treat everything lower priority or
217 // equal to this update as unsuspended.
@@ -232,6 +239,11 @@ export function markRootFinishedAtTime(
239 ): void {
240 // Update the range of pending times
241 root.firstPendingTime = remainingExpirationTime;
242 + if (remainingExpirationTime < root.lastPendingTime) {
243 + // This usually means we've finished all the work, but it can also happen
244 + // when something gets downprioritized during render, like a hidden tree.
245 + root.lastPendingTime = remainingExpirationTime;
246 + }
247
248 // Update the range of suspended times. Treat everything higher priority or
249 // equal to this update as unsuspended.
packages/react-reconciler/src/__tests__/ReactSuspenseList-test.internal.js
+7 -1
@@ -293,7 +293,13 @@ describe('ReactSuspenseList', () => {
293
294 await C.resolve();
295
296 - expect(Scheduler).toFlushAndYield(['C']);
296 + expect(Scheduler).toFlushAndYield([
297 + // TODO: Ideally we wouldn't have to retry B. This is an implementation
298 + // trade off.
299 + 'Suspend! [B]',
300 +
301 + 'C',
302 + ]);
303
304 expect(ReactNoop).toMatchRenderedOutput(
305 <>
packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js
+365 -49
@@ -3,11 +3,12 @@ let ReactFeatureFlags;
3 let Fragment;
4 let ReactNoop;
5 let Scheduler;
6 -let ReactCache;
6 let Suspense;
7 +let textCache;
8
9 -let TextResource;
10 -let textResourceShouldFail;
9 +let readText;
10 +let resolveText;
11 +let rejectText;
12
13 describe('ReactSuspenseWithNoopRenderer', () => {
14 if (!__EXPERIMENTAL__) {
@@ -26,26 +27,74 @@ describe('ReactSuspenseWithNoopRenderer', () => {
27 Fragment = React.Fragment;
28 ReactNoop = require('react-noop-renderer');
29 Scheduler = require('scheduler');
29 - ReactCache = require('react-cache');
30 Suspense = React.Suspense;
31
32 - TextResource = ReactCache.unstable_createResource(
33 - ([text, ms = 0]) => {
34 - return new Promise((resolve, reject) =>
35 - setTimeout(() => {
36 - if (textResourceShouldFail) {
37 - Scheduler.unstable_yieldValue(`Promise rejected [${text}]`);
38 - reject(new Error('Failed to load: ' + text));
39 - } else {
40 - Scheduler.unstable_yieldValue(`Promise resolved [${text}]`);
41 - resolve(text);
42 - }
43 - }, ms),
44 - );
45 - },
46 - ([text, ms]) => text,
47 - );
48 - textResourceShouldFail = false;
32 + textCache = new Map();
33 +
34 + readText = text => {
35 + const record = textCache.get(text);
36 + if (record !== undefined) {
37 + switch (record.status) {
38 + case 'pending':
39 + throw record.promise;
40 + case 'rejected':
41 + throw Error('Failed to load: ' + text);
42 + case 'resolved':
43 + return text;
44 + }
45 + } else {
46 + let ping;
47 + const promise = new Promise(resolve => (ping = resolve));
48 + const newRecord = {
49 + status: 'pending',
50 + ping: ping,
51 + promise,
52 + };
53 + textCache.set(text, newRecord);
54 + throw promise;
55 + }
56 + };
57 +
58 + resolveText = text => {
59 + const record = textCache.get(text);
60 + if (record !== undefined) {
61 + if (record.status === 'pending') {
62 + Scheduler.unstable_yieldValue(`Promise resolved [${text}]`);
63 + record.ping();
64 + record.ping = null;
65 + record.status = 'resolved';
66 + clearTimeout(record.promise._timer);
67 + record.promise = null;
68 + }
69 + } else {
70 + const newRecord = {
71 + ping: null,
72 + status: 'resolved',
73 + promise: null,
74 + };
75 + textCache.set(text, newRecord);
76 + }
77 + };
78 +
79 + rejectText = text => {
80 + const record = textCache.get(text);
81 + if (record !== undefined) {
82 + if (record.status === 'pending') {
83 + Scheduler.unstable_yieldValue(`Promise rejected [${text}]`);
84 + record.ping();
85 + record.status = 'rejected';
86 + clearTimeout(record.promise._timer);
87 + record.promise = null;
88 + }
89 + } else {
90 + const newRecord = {
91 + ping: null,
92 + status: 'rejected',
93 + promise: null,
94 + };
95 + textCache.set(text, newRecord);
96 + }
97 + };
98 });
99
100 // function div(...children) {
@@ -83,12 +132,17 @@ describe('ReactSuspenseWithNoopRenderer', () => {
132 function AsyncText(props) {
133 const text = props.text;
134 try {
86 - TextResource.read([props.text, props.ms]);
135 + readText(text);
136 Scheduler.unstable_yieldValue(text);
137 return <span prop={text} />;
138 } catch (promise) {
139 if (typeof promise.then === 'function') {
140 Scheduler.unstable_yieldValue(`Suspend! [${text}]`);
141 + if (typeof props.ms === 'number' && promise._timer === undefined) {
142 + promise._timer = setTimeout(() => {
143 + resolveText(text);
144 + }, props.ms);
145 + }
146 } else {
147 Scheduler.unstable_yieldValue(`Error! [${text}]`);
148 }
@@ -279,7 +333,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
333 expect(ReactNoop.getChildren()).toEqual([]);
334
335 // Wait for data to resolve
282 - await advanceTimers(100);
336 + await resolveText('B');
337 // Renders successfully
338 expect(Scheduler).toHaveYielded(['Promise resolved [B]']);
339 expect(Scheduler).toFlushAndYield(['A', 'B', 'C', 'D']);
@@ -329,10 +383,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
383 expect(Scheduler).toFlushAndYield(['Suspend! [Result]', 'Loading...']);
384 expect(ReactNoop.getChildren()).toEqual([]);
385
332 - textResourceShouldFail = true;
333 - ReactNoop.expire(1000);
334 - await advanceTimers(1000);
335 - textResourceShouldFail = false;
386 + await rejectText('Result');
387
388 expect(Scheduler).toHaveYielded(['Promise rejected [Result]']);
389
@@ -382,10 +433,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
433 expect(Scheduler).toFlushAndYield(['Suspend! [Result]', 'Loading...']);
434 expect(ReactNoop.getChildren()).toEqual([span('Loading...')]);
435
385 - textResourceShouldFail = true;
386 - ReactNoop.expire(3000);
387 - await advanceTimers(3000);
388 - textResourceShouldFail = false;
436 + await rejectText('Result');
437
438 expect(Scheduler).toHaveYielded(['Promise rejected [Result]']);
439 expect(Scheduler).toFlushAndYield([
@@ -416,7 +464,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
464 // Initial mount
465 ReactNoop.render(<App highPri="A" lowPri="1" />);
466 expect(Scheduler).toFlushAndYield(['A', 'Suspend! [1]', 'Loading...']);
419 - await advanceTimers(0);
467 + await resolveText('1');
468 expect(Scheduler).toHaveYielded(['Promise resolved [1]']);
469 expect(Scheduler).toFlushAndYield(['A', '1']);
470 expect(ReactNoop.getChildren()).toEqual([span('A'), span('1')]);
@@ -439,7 +487,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
487 expect(ReactNoop.getChildren()).toEqual([span('B'), span('1')]);
488
489 // Unblock the low-pri text and finish
442 - await advanceTimers(0);
490 + await resolveText('2');
491 expect(Scheduler).toHaveYielded(['Promise resolved [2]']);
492 expect(ReactNoop.getChildren()).toEqual([span('B'), span('1')]);
493 });
@@ -472,7 +520,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
520 expect(Scheduler).toFlushAndYield(['Suspend! [A]', 'B', 'Loading...']);
521 expect(ReactNoop.getChildren()).toEqual([]);
522
475 - await advanceTimers(0);
523 + await resolveText('A');
524 expect(Scheduler).toHaveYielded(['Promise resolved [A]']);
525 expect(Scheduler).toFlushAndYield(['A', 'B']);
526 expect(ReactNoop.getChildren()).toEqual([span('A'), span('B')]);
@@ -711,7 +759,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
759 expect(ReactNoop.getChildren()).toEqual([span('Loading...'), span('Sync')]);
760
761 // Once the promise resolves, we render the suspended view
714 - await advanceTimers(0);
762 + await resolveText('Async');
763 expect(Scheduler).toHaveYielded(['Promise resolved [Async]']);
764 expect(Scheduler).toFlushAndYield(['Async']);
765 expect(ReactNoop.getChildren()).toEqual([span('Async'), span('Sync')]);
@@ -1212,7 +1260,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
1260 const text = props.text;
1261 Scheduler.unstable_yieldValue('constructor');
1262 try {
1215 - TextResource.read([props.text, props.ms]);
1263 + readText(text);
1264 this.state = {text};
1265 } catch (promise) {
1266 if (typeof promise.then === 'function') {
@@ -1245,7 +1293,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
1293 ]);
1294 expect(ReactNoop.getChildren()).toEqual([span('Loading...')]);
1295
1248 - await advanceTimers(1000);
1296 + await resolveText('Hi');
1297
1298 expect(Scheduler).toHaveYielded(['Promise resolved [Hi]']);
1299 expect(Scheduler).toFlushExpired([
@@ -1287,6 +1335,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
1335 'Suspend! [Hi]',
1336 'Loading...',
1337 // Re-render due to lifecycle update
1338 + 'Suspend! [Hi]',
1339 'Loading...',
1340 ]);
1341 expect(ReactNoop.getChildren()).toEqual([span('Loading...')]);
@@ -1495,9 +1544,8 @@ describe('ReactSuspenseWithNoopRenderer', () => {
1544 }
1545 render() {
1546 const text = this.props.text;
1498 - const ms = this.props.ms;
1547 try {
1500 - TextResource.read([text, ms]);
1548 + readText(text);
1549 Scheduler.unstable_yieldValue(text);
1550 return <span prop={text} />;
1551 } catch (promise) {
@@ -1581,9 +1629,8 @@ describe('ReactSuspenseWithNoopRenderer', () => {
1629 };
1630 }, [props.text]);
1631 const text = props.text;
1584 - const ms = props.ms;
1632 try {
1586 - TextResource.read([text, ms]);
1633 + readText(text);
1634 Scheduler.unstable_yieldValue(text);
1635 return <span prop={text} />;
1636 } catch (promise) {
@@ -1640,8 +1687,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
1687 </>,
1688 );
1689
1643 - Scheduler.unstable_advanceTime(500);
1644 - await advanceTimers(500);
1690 + await resolveText('B');
1691
1692 expect(Scheduler).toHaveYielded(['Promise resolved [B]']);
1693
@@ -1690,8 +1736,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
1736 'Effect [Loading...]',
1737 ]);
1738
1693 - Scheduler.unstable_advanceTime(500);
1694 - await advanceTimers(500);
1739 + await resolveText('B2');
1740
1741 expect(Scheduler).toHaveYielded(['Promise resolved [B2]']);
1742
@@ -2068,8 +2113,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
2113 await ReactNoop.act(async () => show(true));
2114
2115 expect(Scheduler).toHaveYielded(['Suspend! [A]']);
2071 - Scheduler.unstable_advanceTime(100);
2072 - await advanceTimers(100);
2116 + await resolveText('A');
2117
2118 expect(Scheduler).toHaveYielded(['Promise resolved [A]']);
2119 });
@@ -2094,8 +2138,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
2138 await ReactNoop.act(async () => _setShow(true));
2139
2140 expect(Scheduler).toHaveYielded(['Suspend! [A]']);
2097 - Scheduler.unstable_advanceTime(100);
2098 - await advanceTimers(100);
2141 + await resolveText('A');
2142
2143 expect(Scheduler).toHaveYielded(['Promise resolved [A]']);
2144 });
@@ -2829,6 +2872,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
2872 foo.setState({suspend: false});
2873 });
2874
2875 + expect(Scheduler).toHaveYielded(['Foo']);
2876 expect(root).toMatchRenderedOutput(<span prop="Foo" />);
2877 });
2878
@@ -2944,4 +2988,276 @@ describe('ReactSuspenseWithNoopRenderer', () => {
2988 </>,
2989 );
2990 });
2991 +
2992 + it(
2993 + 'multiple updates originating inside a Suspense boundary at different ' +
2994 + 'priority levels are not dropped',
2995 + async () => {
2996 + const {useState} = React;
2997 + const root = ReactNoop.createRoot();
2998 +
2999 + function Parent() {
3000 + return (
3001 + <>
3002 + <Suspense fallback={<Text text="Loading..." />}>
3003 + <Child />
3004 + </Suspense>
3005 + </>
3006 + );
3007 + }
3008 +
3009 + let setText;
3010 + function Child() {
3011 + const [text, _setText] = useState('A');
3012 + setText = _setText;
3013 + return <AsyncText text={text} />;
3014 + }
3015 +
3016 + await resolveText('A');
3017 + await ReactNoop.act(async () => {
3018 + root.render(<Parent />);
3019 + });
3020 + expect(Scheduler).toHaveYielded(['A']);
3021 + expect(root).toMatchRenderedOutput(<span prop="A" />);
3022 +
3023 + await ReactNoop.act(async () => {
3024 + // Schedule two updates that originate inside the Suspense boundary.
3025 + // The first one causes the boundary to suspend. The second one is at
3026 + // lower priority and unsuspends the tree.
3027 + ReactNoop.discreteUpdates(() => {
3028 + setText('B');
3029 + });
3030 + // Update to a value that has already resolved
3031 + await resolveText('C');
3032 + setText('C');
3033 + });
3034 + expect(Scheduler).toHaveYielded([
3035 + // First we attempt the high pri update. It suspends.
3036 + 'Suspend! [B]',
3037 + 'Loading...',
3038 + // Then we attempt the low pri update, which finishes successfully.
3039 + 'C',
3040 + ]);
3041 + expect(root).toMatchRenderedOutput(<span prop="C" />);
3042 + },
3043 + );
3044 +
3045 + it(
3046 + 'multiple updates originating inside a Suspense boundary at different ' +
3047 + 'priority levels are not dropped, including Idle updates',
3048 + async () => {
3049 + const {useState} = React;
3050 + const root = ReactNoop.createRoot();
3051 +
3052 + function Parent() {
3053 + return (
3054 + <>
3055 + <Suspense fallback={<Text text="Loading..." />}>
3056 + <Child />
3057 + </Suspense>
3058 + </>
3059 + );
3060 + }
3061 +
3062 + let setText;
3063 + function Child() {
3064 + const [text, _setText] = useState('A');
3065 + setText = _setText;
3066 + return <AsyncText text={text} />;
3067 + }
3068 +
3069 + await resolveText('A');
3070 + await ReactNoop.act(async () => {
3071 + root.render(<Parent />);
3072 + });
3073 + expect(Scheduler).toHaveYielded(['A']);
3074 + expect(root).toMatchRenderedOutput(<span prop="A" />);
3075 +
3076 + await ReactNoop.act(async () => {
3077 + // Schedule two updates that originate inside the Suspense boundary.
3078 + // The first one causes the boundary to suspend. The second one is at
3079 + // lower priority and unsuspends it by hiding the async component.
3080 + setText('B');
3081 +
3082 + await resolveText('C');
3083 + Scheduler.unstable_runWithPriority(
3084 + Scheduler.unstable_IdlePriority,
3085 + () => {
3086 + setText('C');
3087 + },
3088 + );
3089 + });
3090 + expect(Scheduler).toHaveYielded([
3091 + // First we attempt the high pri update. It suspends.
3092 + 'Suspend! [B]',
3093 + 'Loading...',
3094 + ]);
3095 +
3096 + // Commit the placeholder to unblock the Idle update.
3097 + await advanceTimers(250);
3098 + expect(root).toMatchRenderedOutput(
3099 + <>
3100 + <span hidden={true} prop="A" />
3101 + <span prop="Loading..." />
3102 + </>,
3103 + );
3104 +
3105 + // Now flush the remaining work. The Idle update successfully finishes.
3106 + expect(Scheduler).toFlushAndYield(['C']);
3107 + expect(root).toMatchRenderedOutput(<span prop="C" />);
3108 + },
3109 + );
3110 +
3111 + it(
3112 + 'fallback component can update itself even after a high pri update to ' +
3113 + 'the primary tree suspends',
3114 + async () => {
3115 + const {useState} = React;
3116 + const root = ReactNoop.createRoot();
3117 +
3118 + let setAppText;
3119 + function App() {
3120 + const [text, _setText] = useState('A');
3121 + setAppText = _setText;
3122 + return (
3123 + <>
3124 + <Suspense fallback={<Fallback />}>
3125 + <AsyncText text={text} />
3126 + </Suspense>
3127 + </>
3128 + );
3129 + }
3130 +
3131 + let setFallbackText;
3132 + function Fallback() {
3133 + const [text, _setText] = useState('Loading...');
3134 + setFallbackText = _setText;
3135 + return <Text text={text} />;
3136 + }
3137 +
3138 + // Resolve the initial tree
3139 + await resolveText('A');
3140 + await ReactNoop.act(async () => {
3141 + root.render(<App />);
3142 + });
3143 + expect(Scheduler).toHaveYielded(['A']);
3144 + expect(root).toMatchRenderedOutput(<span prop="A" />);
3145 +
3146 + // Schedule an update inside the Suspense boundary that suspends.
3147 + await ReactNoop.act(async () => {
3148 + setAppText('B');
3149 + });
3150 + expect(Scheduler).toHaveYielded(['Suspend! [B]', 'Loading...']);
3151 + // Commit the placeholder
3152 + await advanceTimers(250);
3153 + expect(root).toMatchRenderedOutput(
3154 + <>
3155 + <span hidden={true} prop="A" />
3156 + <span prop="Loading..." />
3157 + </>,
3158 + );
3159 +
3160 + // Schedule a high pri update on the boundary, and a lower pri update
3161 + // on the fallback. We're testing to make sure the fallback can still
3162 + // update even though the primary tree is suspended.
3163 + await ReactNoop.act(async () => {
3164 + ReactNoop.discreteUpdates(() => {
3165 + setAppText('C');
3166 + });
3167 + setFallbackText('Still loading...');
3168 + });
3169 +
3170 + expect(Scheduler).toHaveYielded([
3171 + // First try to update the suspended tree. It's still suspended.
3172 + 'Suspend! [C]',
3173 + 'Loading...',
3174 + // Then complete the update to the fallback.
3175 + 'Still loading...',
3176 + ]);
3177 + expect(root).toMatchRenderedOutput(
3178 + <>
3179 + <span hidden={true} prop="A" />
3180 + <span prop="Still loading..." />
3181 + </>,
3182 + );
3183 + },
3184 + );
3185 +
3186 + it(
3187 + 'regression: primary fragment fiber is not always part of setState ' +
3188 + 'return path',
3189 + async () => {
3190 + // Reproduces a bug where updates inside a suspended tree are dropped
3191 + // because the fragment fiber we insert to wrap the hidden children is not
3192 + // part of the return path, so it doesn't get marked during setState.
3193 + const {useState} = React;
3194 + const root = ReactNoop.createRoot();
3195 +
3196 + function Parent() {
3197 + return (
3198 + <>
3199 + <Suspense fallback={<Text text="Loading..." />}>
3200 + <Child />
3201 + </Suspense>
3202 + </>
3203 + );
3204 + }
3205 +
3206 + let setText;
3207 + function Child() {
3208 + const [text, _setText] = useState('A');
3209 + setText = _setText;
3210 + return <AsyncText text={text} />;
3211 + }
3212 +
3213 + // Mount an initial tree. Resolve A so that it doesn't suspend.
3214 + await resolveText('A');
3215 + await ReactNoop.act(async () => {
3216 + root.render(<Parent />);
3217 + });
3218 + expect(Scheduler).toHaveYielded(['A']);
3219 + // At this point, the setState return path follows current fiber.
3220 + expect(root).toMatchRenderedOutput(<span prop="A" />);
3221 +
3222 + // Schedule another update. This will "flip" the alternate pairs.
3223 + await resolveText('B');
3224 + await ReactNoop.act(async () => {
3225 + setText('B');
3226 + });
3227 + expect(Scheduler).toHaveYielded(['B']);
3228 + // Now the setState return path follows the *alternate* fiber.
3229 + expect(root).toMatchRenderedOutput(<span prop="B" />);
3230 +
3231 + // Schedule another update. This time, we'll suspend.
3232 + await ReactNoop.act(async () => {
3233 + setText('C');
3234 + });
3235 + expect(Scheduler).toHaveYielded(['Suspend! [C]', 'Loading...']);
3236 +
3237 + // Commit. This will insert a fragment fiber to wrap around the component
3238 + // that triggered the update.
3239 + await ReactNoop.act(async () => {
3240 + await advanceTimers(250);
3241 + });
3242 + // The fragment fiber is part of the current tree, but the setState return
3243 + // path still follows the alternate path. That means the fragment fiber is
3244 + // not part of the return path.
3245 + expect(root).toMatchRenderedOutput(
3246 + <>
3247 + <span hidden={true} prop="B" />
3248 + <span prop="Loading..." />
3249 + </>,
3250 + );
3251 +
3252 + // Update again. This should unsuspend the tree.
3253 + await resolveText('D');
3254 + await ReactNoop.act(async () => {
3255 + setText('D');
3256 + });
3257 + // Even though the fragment fiber is not part of the return path, we should
3258 + // be able to finish rendering.
3259 + expect(Scheduler).toHaveYielded(['D']);
3260 + expect(root).toMatchRenderedOutput(<span prop="D" />);
3261 + },
3262 + );
3263 });