Fix for failed Suspense layout semantics (#21694)
Co-authored-by: Dan Abramov <dan.abramov@me.com>
Brian Vaughn committed
Jun 16, 2021 at 19:44 UTC
d0f348dc1b1ab1c3f7377f918d8ffd3fa64c6918
3 files changed
+112
-56
packages/react-reconciler/src/ReactFiberCommitWork.new.js
+27
-28
@@ -2210,36 +2210,35 @@ function commitLayoutEffects_begin(
2210
commitLayoutMountEffects_complete(subtreeRoot, root, committedLanes);
2211
continue;
2212
} else {
2213
- if ((fiber.subtreeFlags & LayoutMask) !== NoFlags) {
2214
- const current = fiber.alternate;
2215
- const wasHidden = current !== null && current.memoizedState !== null;
2216
- const newOffscreenSubtreeWasHidden =
2217
- wasHidden || offscreenSubtreeWasHidden;
2218
- const prevOffscreenSubtreeIsHidden = offscreenSubtreeIsHidden;
2219
- const prevOffscreenSubtreeWasHidden = offscreenSubtreeWasHidden;
2220
-
2221
- // Traverse the Offscreen subtree with the current Offscreen as the root.
2222
- offscreenSubtreeIsHidden = newOffscreenSubtreeIsHidden;
2223
- offscreenSubtreeWasHidden = newOffscreenSubtreeWasHidden;
2224
- let child = firstChild;
2225
- while (child !== null) {
2226
- nextEffect = child;
2227
- commitLayoutEffects_begin(
2228
- child, // New root; bubble back up to here and stop.
2229
- root,
2230
- committedLanes,
2231
- );
2232
- child = child.sibling;
2233
- }
2213
+ // TODO (Offscreen) Also check: subtreeFlags & LayoutMask
2214
+ const current = fiber.alternate;
2215
+ const wasHidden = current !== null && current.memoizedState !== null;
2216
+ const newOffscreenSubtreeWasHidden =
2217
+ wasHidden || offscreenSubtreeWasHidden;
2218
+ const prevOffscreenSubtreeIsHidden = offscreenSubtreeIsHidden;
2219
+ const prevOffscreenSubtreeWasHidden = offscreenSubtreeWasHidden;
2220
+
2221
+ // Traverse the Offscreen subtree with the current Offscreen as the root.
2222
+ offscreenSubtreeIsHidden = newOffscreenSubtreeIsHidden;
2223
+ offscreenSubtreeWasHidden = newOffscreenSubtreeWasHidden;
2224
+ let child = firstChild;
2225
+ while (child !== null) {
2226
+ nextEffect = child;
2227
+ commitLayoutEffects_begin(
2228
+ child, // New root; bubble back up to here and stop.
2229
+ root,
2230
+ committedLanes,
2231
+ );
2232
+ child = child.sibling;
2233
+ }
2234
2235
- // Restore Offscreen state and resume in our-progress traversal.
2236
- nextEffect = fiber;
2237
- offscreenSubtreeIsHidden = prevOffscreenSubtreeIsHidden;
2238
- offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden;
2239
- commitLayoutMountEffects_complete(subtreeRoot, root, committedLanes);
2235
+ // Restore Offscreen state and resume in our-progress traversal.
2236
+ nextEffect = fiber;
2237
+ offscreenSubtreeIsHidden = prevOffscreenSubtreeIsHidden;
2238
+ offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden;
2239
+ commitLayoutMountEffects_complete(subtreeRoot, root, committedLanes);
2240
2241
- continue;
2242
- }
2241
+ continue;
2242
}
2243
}
2244
packages/react-reconciler/src/ReactFiberCommitWork.old.js
+27
-28
@@ -2210,36 +2210,35 @@ function commitLayoutEffects_begin(
2210
commitLayoutMountEffects_complete(subtreeRoot, root, committedLanes);
2211
continue;
2212
} else {
2213
- if ((fiber.subtreeFlags & LayoutMask) !== NoFlags) {
2214
- const current = fiber.alternate;
2215
- const wasHidden = current !== null && current.memoizedState !== null;
2216
- const newOffscreenSubtreeWasHidden =
2217
- wasHidden || offscreenSubtreeWasHidden;
2218
- const prevOffscreenSubtreeIsHidden = offscreenSubtreeIsHidden;
2219
- const prevOffscreenSubtreeWasHidden = offscreenSubtreeWasHidden;
2220
-
2221
- // Traverse the Offscreen subtree with the current Offscreen as the root.
2222
- offscreenSubtreeIsHidden = newOffscreenSubtreeIsHidden;
2223
- offscreenSubtreeWasHidden = newOffscreenSubtreeWasHidden;
2224
- let child = firstChild;
2225
- while (child !== null) {
2226
- nextEffect = child;
2227
- commitLayoutEffects_begin(
2228
- child, // New root; bubble back up to here and stop.
2229
- root,
2230
- committedLanes,
2231
- );
2232
- child = child.sibling;
2233
- }
2213
+ // TODO (Offscreen) Also check: subtreeFlags & LayoutMask
2214
+ const current = fiber.alternate;
2215
+ const wasHidden = current !== null && current.memoizedState !== null;
2216
+ const newOffscreenSubtreeWasHidden =
2217
+ wasHidden || offscreenSubtreeWasHidden;
2218
+ const prevOffscreenSubtreeIsHidden = offscreenSubtreeIsHidden;
2219
+ const prevOffscreenSubtreeWasHidden = offscreenSubtreeWasHidden;
2220
+
2221
+ // Traverse the Offscreen subtree with the current Offscreen as the root.
2222
+ offscreenSubtreeIsHidden = newOffscreenSubtreeIsHidden;
2223
+ offscreenSubtreeWasHidden = newOffscreenSubtreeWasHidden;
2224
+ let child = firstChild;
2225
+ while (child !== null) {
2226
+ nextEffect = child;
2227
+ commitLayoutEffects_begin(
2228
+ child, // New root; bubble back up to here and stop.
2229
+ root,
2230
+ committedLanes,
2231
+ );
2232
+ child = child.sibling;
2233
+ }
2234
2235
- // Restore Offscreen state and resume in our-progress traversal.
2236
- nextEffect = fiber;
2237
- offscreenSubtreeIsHidden = prevOffscreenSubtreeIsHidden;
2238
- offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden;
2239
- commitLayoutMountEffects_complete(subtreeRoot, root, committedLanes);
2235
+ // Restore Offscreen state and resume in our-progress traversal.
2236
+ nextEffect = fiber;
2237
+ offscreenSubtreeIsHidden = prevOffscreenSubtreeIsHidden;
2238
+ offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden;
2239
+ commitLayoutMountEffects_complete(subtreeRoot, root, committedLanes);
2240
2241
- continue;
2242
- }
2241
+ continue;
2242
}
2243
}
2244
packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js
+58
@@ -663,6 +663,64 @@ describe('ReactSuspense', () => {
663
expect(root).toMatchRenderedOutput('new value');
664
});
665
666
+ // @gate enableSuspenseLayoutEffectSemantics
667
+ it('re-fires layout effects when re-showing Suspense', () => {
668
+ function TextWithLayout(props) {
669
+ Scheduler.unstable_yieldValue(props.text);
670
+ React.useLayoutEffect(() => {
671
+ Scheduler.unstable_yieldValue('create layout');
672
+ return () => {
673
+ Scheduler.unstable_yieldValue('destroy layout');
674
+ };
675
+ }, []);
676
+ return props.text;
677
+ }
678
+
679
+ let _setShow;
680
+ function App(props) {
681
+ const [show, setShow] = React.useState(false);
682
+ _setShow = setShow;
683
+ return (
684
+ <Suspense fallback={<Text text="Loading..." />}>
685
+ <TextWithLayout text="Child 1" />
686
+ {show && <AsyncText ms={1000} text="Child 2" />}
687
+ </Suspense>
688
+ );
689
+ }
690
+
691
+ const root = ReactTestRenderer.create(<App />, {
692
+ unstable_isConcurrent: true,
693
+ });
694
+
695
+ expect(Scheduler).toFlushAndYield(['Child 1', 'create layout']);
696
+ expect(root).toMatchRenderedOutput('Child 1');
697
+
698
+ ReactTestRenderer.act(() => {
699
+ _setShow(true);
700
+ });
701
+ expect(Scheduler).toHaveYielded(
702
+ // DEV behavior differs from prod
703
+ // In DEV sometimes the work loop sync-flushes the commit
704
+ // where as in production, it schedules it behind a timeout.
705
+ // See shouldForceFlushFallbacksInDEV() usage
706
+ __DEV__
707
+ ? ['Child 1', 'Suspend! [Child 2]', 'Loading...', 'destroy layout']
708
+ : ['Child 1', 'Suspend! [Child 2]', 'Loading...'],
709
+ );
710
+ jest.advanceTimersByTime(1000);
711
+ expect(Scheduler).toHaveYielded(
712
+ // DEV behavior differs from prod
713
+ // In DEV sometimes the work loop sync-flushes the commit
714
+ // where as in production, it schedules it behind a timeout.
715
+ // See shouldForceFlushFallbacksInDEV() usage
716
+ __DEV__
717
+ ? ['Promise resolved [Child 2]']
718
+ : ['destroy layout', 'Promise resolved [Child 2]'],
719
+ );
720
+ expect(Scheduler).toFlushAndYield(['Child 1', 'Child 2', 'create layout']);
721
+ expect(root).toMatchRenderedOutput(['Child 1', 'Child 2'].join(''));
722
+ });
723
+
724
describe('outside concurrent mode', () => {
725
it('a mounted class component can suspend without losing state', () => {
726
class TextWithLifecycle extends React.Component {