[Suspense] Fix bad loading state not being delayed (#15891)
Fixes a bug where a bad loading state is initially suspended, but a subsequent update with the same expiration time causes it to commit immediately.
Andrew Clark committed
Jun 14, 2019 at 16:10 UTC
788da69b74d2311ae2669f7936a9a33ec2469241
2 files changed
+53
-1
packages/react-reconciler/src/ReactFiberWorkLoop.js
+6
-1
@@ -323,6 +323,9 @@ export function computeExpirationForFiber(
323
324
// If we're in the middle of rendering a tree, do not update at the same
325
// expiration time that is already rendering.
326
+ // TODO: We shouldn't have to do this if the update is on a different root.
327
+ // Refactor computeExpirationForFiber + scheduleUpdate so we have access to
328
+ // the root when we check for this condition.
329
if (workInProgressRoot !== null && expirationTime === renderExpirationTime) {
330
// This is a trick to move this update into a separate batch
331
expirationTime -= 1;
@@ -806,8 +809,10 @@ function renderRoot(
809
return null;
810
}
811
809
- if (root.finishedExpirationTime === expirationTime) {
812
+ if (isSync && root.finishedExpirationTime === expirationTime) {
813
// There's already a pending commit at this expiration time.
814
+ // TODO: This is poorly factored. This case only exists for the
815
+ // batch.commit() API.
816
return commitRoot.bind(null, root);
817
}
818
packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js
+47
@@ -2294,4 +2294,51 @@ describe('ReactSuspenseWithNoopRenderer', () => {
2294
expect(ReactNoop.getChildren()).toEqual([span('D')]);
2295
});
2296
});
2297
+
2298
+ it("suspended commit remains suspended even if there's another update at same expiration", async () => {
2299
+ // Regression test
2300
+ function App({text}) {
2301
+ return (
2302
+ <Suspense fallback="Outer fallback">
2303
+ <AsyncText ms={2000} text={text} />
2304
+ </Suspense>
2305
+ );
2306
+ }
2307
+
2308
+ const root = ReactNoop.createRoot();
2309
+ await ReactNoop.act(async () => {
2310
+ root.render(<App text="Initial" />);
2311
+ });
2312
+
2313
+ // Resolve initial render
2314
+ await ReactNoop.act(async () => {
2315
+ Scheduler.advanceTime(2000);
2316
+ await advanceTimers(2000);
2317
+ });
2318
+ expect(Scheduler).toHaveYielded([
2319
+ 'Suspend! [Initial]',
2320
+ 'Promise resolved [Initial]',
2321
+ 'Initial',
2322
+ ]);
2323
+ expect(root).toMatchRenderedOutput(<span prop="Initial" />);
2324
+
2325
+ // Suspend B. Since showing a fallback would hide content that's already
2326
+ // visible, it should suspend for a bit without committing.
2327
+ await ReactNoop.act(async () => {
2328
+ root.render(<App text="First update" />);
2329
+
2330
+ expect(Scheduler).toFlushAndYield(['Suspend! [First update]']);
2331
+ // Should not display a fallback
2332
+ expect(root).toMatchRenderedOutput(<span prop="Initial" />);
2333
+ });
2334
+
2335
+ // Suspend A. This should also suspend for a JND.
2336
+ await ReactNoop.act(async () => {
2337
+ root.render(<App text="Second update" />);
2338
+
2339
+ expect(Scheduler).toFlushAndYield(['Suspend! [Second update]']);
2340
+ // Should not display a fallback
2341
+ expect(root).toMatchRenderedOutput(<span prop="Initial" />);
2342
+ });
2343
+ });
2344
});