Skip to content

Commit e27720d

Browse files
authored
[Synchronous Suspense] Reuse deletions from primary tree (#14133)
Fixes a bug where deletion effects in the primary tree were dropped before entering the second render pass. Because we no longer reset the effect list after the first render pass, I've also moved the deletion of the fallback children to the complete phase, after the tree successfully renders without suspending. Will need to revisit this heuristic when we implement resuming.
1 parent aa1ffe4 commit e27720d

File tree

4 files changed

+88
-68
lines changed

4 files changed

+88
-68
lines changed

packages/react-reconciler/src/ReactFiberBeginWork.js

+10-66
Original file line numberDiff line numberDiff line change
@@ -1127,11 +1127,7 @@ function updateSuspenseComponent(
11271127
progressedState !== null
11281128
? (workInProgress.child: any).child
11291129
: (workInProgress.child: any);
1130-
reuseProgressedPrimaryChild(
1131-
workInProgress,
1132-
primaryChildFragment,
1133-
progressedPrimaryChild,
1134-
);
1130+
primaryChildFragment.child = progressedPrimaryChild;
11351131
}
11361132

11371133
const fallbackChildFragment = createFiberFromFragment(
@@ -1186,11 +1182,7 @@ function updateSuspenseComponent(
11861182
? (workInProgress.child: any).child
11871183
: (workInProgress.child: any);
11881184
if (progressedPrimaryChild !== currentPrimaryChildFragment.child) {
1189-
reuseProgressedPrimaryChild(
1190-
workInProgress,
1191-
primaryChildFragment,
1192-
progressedPrimaryChild,
1193-
);
1185+
primaryChildFragment.child = progressedPrimaryChild;
11941186
}
11951187
}
11961188

@@ -1213,20 +1205,19 @@ function updateSuspenseComponent(
12131205
// and remove the intermediate fragment fiber.
12141206
const nextPrimaryChildren = nextProps.children;
12151207
const currentPrimaryChild = currentPrimaryChildFragment.child;
1216-
const currentFallbackChild = currentFallbackChildFragment.child;
12171208
const primaryChild = reconcileChildFibers(
12181209
workInProgress,
12191210
currentPrimaryChild,
12201211
nextPrimaryChildren,
12211212
renderExpirationTime,
12221213
);
1223-
// Delete the fallback children.
1224-
reconcileChildFibers(
1225-
workInProgress,
1226-
currentFallbackChild,
1227-
null,
1228-
renderExpirationTime,
1229-
);
1214+
1215+
// If this render doesn't suspend, we need to delete the fallback
1216+
// children. Wait until the complete phase, after we've confirmed the
1217+
// fallback is no longer needed.
1218+
// TODO: Would it be better to store the fallback fragment on
1219+
// the stateNode?
1220+
12301221
// Continue rendering the children, like we normally do.
12311222
child = next = primaryChild;
12321223
}
@@ -1258,11 +1249,7 @@ function updateSuspenseComponent(
12581249
progressedState !== null
12591250
? (workInProgress.child: any).child
12601251
: (workInProgress.child: any);
1261-
reuseProgressedPrimaryChild(
1262-
workInProgress,
1263-
primaryChildFragment,
1264-
progressedPrimaryChild,
1265-
);
1252+
primaryChildFragment.child = progressedPrimaryChild;
12661253
}
12671254

12681255
// Create a fragment from the fallback children, too.
@@ -1298,49 +1285,6 @@ function updateSuspenseComponent(
12981285
return next;
12991286
}
13001287

1301-
function reuseProgressedPrimaryChild(
1302-
workInProgress: Fiber,
1303-
primaryChildFragment: Fiber,
1304-
progressedChild: Fiber | null,
1305-
) {
1306-
// This function is only called outside concurrent mode. Usually, if a work-
1307-
// in-progress primary tree suspends, we throw it out and revert back to
1308-
// current. Outside concurrent mode, though, we commit the suspended work-in-
1309-
// progress, even though it didn't complete. This function reuses the children
1310-
// and transfers the effects.
1311-
let child = (primaryChildFragment.child = progressedChild);
1312-
while (child !== null) {
1313-
// Ensure that the first and last effect of the parent corresponds
1314-
// to the children's first and last effect.
1315-
if (primaryChildFragment.firstEffect === null) {
1316-
primaryChildFragment.firstEffect = child.firstEffect;
1317-
}
1318-
if (child.lastEffect !== null) {
1319-
if (primaryChildFragment.lastEffect !== null) {
1320-
primaryChildFragment.lastEffect.nextEffect = child.firstEffect;
1321-
}
1322-
primaryChildFragment.lastEffect = child.lastEffect;
1323-
}
1324-
1325-
// Append all the effects of the subtree and this fiber onto the effect
1326-
// list of the parent. The completion order of the children affects the
1327-
// side-effect order.
1328-
if (child.effectTag > PerformedWork) {
1329-
if (primaryChildFragment.lastEffect !== null) {
1330-
primaryChildFragment.lastEffect.nextEffect = child;
1331-
} else {
1332-
primaryChildFragment.firstEffect = child;
1333-
}
1334-
primaryChildFragment.lastEffect = child;
1335-
}
1336-
child.return = primaryChildFragment;
1337-
child = child.sibling;
1338-
}
1339-
1340-
workInProgress.firstEffect = primaryChildFragment.firstEffect;
1341-
workInProgress.lastEffect = primaryChildFragment.lastEffect;
1342-
}
1343-
13441288
function updatePortalComponent(
13451289
current: Fiber | null,
13461290
workInProgress: Fiber,

packages/react-reconciler/src/ReactFiberCompleteWork.js

+19-1
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ import {
8282
popHydrationState,
8383
} from './ReactFiberHydrationContext';
8484
import {ConcurrentMode, NoContext} from './ReactTypeOfMode';
85+
import {reconcileChildFibers} from './ReactChildFiber';
8586

8687
function markUpdate(workInProgress: Fiber) {
8788
// Tag the fiber with an update effect. This turns a Placement into
@@ -700,12 +701,29 @@ function completeWork(
700701
if ((workInProgress.effectTag & DidCapture) !== NoEffect) {
701702
// Something suspended. Re-render with the fallback children.
702703
workInProgress.expirationTime = renderExpirationTime;
703-
workInProgress.firstEffect = workInProgress.lastEffect = null;
704+
// Do not reset the effect list.
704705
return workInProgress;
705706
}
706707

707708
const nextDidTimeout = nextState !== null;
708709
const prevDidTimeout = current !== null && current.memoizedState !== null;
710+
711+
if (current !== null && !nextDidTimeout && prevDidTimeout) {
712+
// We just switched from the fallback to the normal children. Delete
713+
// the fallback.
714+
// TODO: Would it be better to store the fallback fragment on
715+
// the stateNode during the begin phase?
716+
const currentFallbackChild: Fiber | null = (current.child: any).sibling;
717+
if (currentFallbackChild !== null) {
718+
reconcileChildFibers(
719+
workInProgress,
720+
currentFallbackChild,
721+
null,
722+
renderExpirationTime,
723+
);
724+
}
725+
}
726+
709727
// The children either timed out after previously being visible, or
710728
// were restored after previously being hidden. Schedule an effect
711729
// to update their visiblity.

packages/react-reconciler/src/ReactFiberScheduler.js

-1
Original file line numberDiff line numberDiff line change
@@ -993,7 +993,6 @@ function completeUnitOfWork(workInProgress: Fiber): Fiber | null {
993993

994994
if (nextUnitOfWork !== null) {
995995
// Completing this fiber spawned new work. Work on that next.
996-
nextUnitOfWork.firstEffect = nextUnitOfWork.lastEffect = null;
997996
return nextUnitOfWork;
998997
}
999998

packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js

+59
Original file line numberDiff line numberDiff line change
@@ -779,5 +779,64 @@ describe('ReactSuspense', () => {
779779
expect(root).toFlushAndYield(['Child 1', 'Child 2']);
780780
expect(root).toMatchRenderedOutput(['Child 1', 'Child 2'].join(''));
781781
});
782+
783+
it('reuses effects, including deletions, from the suspended tree', () => {
784+
const {useState} = React;
785+
786+
let setTab;
787+
function App() {
788+
const [tab, _setTab] = useState(0);
789+
setTab = _setTab;
790+
791+
return (
792+
<Suspense fallback={<Text text="Loading..." />}>
793+
<AsyncText key={tab} text={'Tab: ' + tab} ms={1000} />
794+
<Text key={tab + 'sibling'} text=" + sibling" />
795+
</Suspense>
796+
);
797+
}
798+
799+
const root = ReactTestRenderer.create(<App />);
800+
expect(ReactTestRenderer).toHaveYielded([
801+
'Suspend! [Tab: 0]',
802+
' + sibling',
803+
'Loading...',
804+
]);
805+
expect(root).toMatchRenderedOutput('Loading...');
806+
jest.advanceTimersByTime(1000);
807+
expect(ReactTestRenderer).toHaveYielded([
808+
'Promise resolved [Tab: 0]',
809+
'Tab: 0',
810+
]);
811+
expect(root).toMatchRenderedOutput('Tab: 0 + sibling');
812+
813+
setTab(1);
814+
expect(ReactTestRenderer).toHaveYielded([
815+
'Suspend! [Tab: 1]',
816+
' + sibling',
817+
'Loading...',
818+
]);
819+
expect(root).toMatchRenderedOutput('Loading...');
820+
jest.advanceTimersByTime(1000);
821+
expect(ReactTestRenderer).toHaveYielded([
822+
'Promise resolved [Tab: 1]',
823+
'Tab: 1',
824+
]);
825+
expect(root).toMatchRenderedOutput('Tab: 1 + sibling');
826+
827+
setTab(2);
828+
expect(ReactTestRenderer).toHaveYielded([
829+
'Suspend! [Tab: 2]',
830+
' + sibling',
831+
'Loading...',
832+
]);
833+
expect(root).toMatchRenderedOutput('Loading...');
834+
jest.advanceTimersByTime(1000);
835+
expect(ReactTestRenderer).toHaveYielded([
836+
'Promise resolved [Tab: 2]',
837+
'Tab: 2',
838+
]);
839+
expect(root).toMatchRenderedOutput('Tab: 2 + sibling');
840+
});
782841
});
783842
});

0 commit comments

Comments
 (0)