Internal `act`: Flush timers at end of scope (#19788)

If there are any suspended fallbacks at the end of the `act` scope,
force them to display by running the pending timers (i.e. `setTimeout`).

The public implementation of `act` achieves the same behavior with an
extra check in the work loop (`shouldForceFlushFallbacks`). Since our
internal `act` needs to work in both development and production, without
additional runtime checks, we instead rely on Jest's mock timers.

This doesn't not affect refresh transitions, which are meant to delay
indefinitely, because in that case we exit the work loop without
posting a timer.
This commit is contained in:
Andrew Clark 2020-09-08 23:55:23 -05:00 committed by GitHub
parent d17086c7c8
commit e7b255341b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 48 additions and 35 deletions

View File

@ -126,8 +126,9 @@ export function unstable_concurrentAct(scope: () => Thenable<mixed> | void) {
}
function flushActWork(resolve, reject) {
// TODO: Run timers to flush suspended fallbacks
// jest.runOnlyPendingTimers();
// Flush suspended fallbacks
// $FlowFixMe: Flow doesn't know about global Jest object
jest.runOnlyPendingTimers();
enqueueTask(() => {
try {
const didFlushWork = Scheduler.unstable_flushAllWithoutAsserting();

View File

@ -1175,8 +1175,9 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
}
function flushActWork(resolve, reject) {
// TODO: Run timers to flush suspended fallbacks
// jest.runOnlyPendingTimers();
// Flush suspended fallbacks
// $FlowFixMe: Flow doesn't know about global Jest object
jest.runOnlyPendingTimers();
enqueueTask(() => {
try {
const didFlushWork = Scheduler.unstable_flushAllWithoutAsserting();

View File

@ -14,6 +14,7 @@ let useState;
let Suspense;
let block;
let readString;
let resolvePromises;
let Scheduler;
describe('ReactBlocks', () => {
@ -28,15 +29,16 @@ describe('ReactBlocks', () => {
useState = React.useState;
Suspense = React.Suspense;
const cache = new Map();
let unresolved = [];
readString = function(text) {
let entry = cache.get(text);
if (!entry) {
entry = {
promise: new Promise(resolve => {
setTimeout(() => {
unresolved.push(() => {
entry.resolved = true;
resolve();
}, 100);
});
}),
resolved: false,
};
@ -47,6 +49,12 @@ describe('ReactBlocks', () => {
}
return text;
};
resolvePromises = () => {
const res = unresolved;
unresolved = [];
res.forEach(r => r());
};
});
// @gate experimental
@ -144,7 +152,7 @@ describe('ReactBlocks', () => {
expect(ReactNoop).toMatchRenderedOutput('Loading...');
await ReactNoop.act(async () => {
jest.advanceTimersByTime(1000);
resolvePromises();
});
expect(ReactNoop).toMatchRenderedOutput(<span>Name: Sebastian</span>);
@ -291,7 +299,7 @@ describe('ReactBlocks', () => {
ReactNoop.render(<App Page={loadParent('Sebastian')} />);
});
await ReactNoop.act(async () => {
jest.advanceTimersByTime(1000);
resolvePromises();
});
expect(ReactNoop).toMatchRenderedOutput(<span>Name: Sebastian</span>);
});
@ -336,7 +344,7 @@ describe('ReactBlocks', () => {
});
await ReactNoop.act(async () => {
_setSuspend(false);
jest.advanceTimersByTime(1000);
resolvePromises();
});
expect(ReactNoop).toMatchRenderedOutput(<span>Sebastian</span>);
});

View File

@ -575,7 +575,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
<Suspense fallback="Loading...">
<Text text="Sibling" />
{shouldSuspend ? (
<AsyncText ms={10000} text={'Step ' + step} />
<AsyncText text={'Step ' + step} />
) : (
<Text text={'Step ' + step} />
)}
@ -2595,7 +2595,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
}
return (
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text={page} ms={5000} />
<AsyncText text={page} />
</Suspense>
);
}
@ -2616,8 +2616,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
});
// Later we load the data.
Scheduler.unstable_advanceTime(5000);
await advanceTimers(5000);
await resolveText('A');
expect(Scheduler).toHaveYielded(['Promise resolved [A]']);
expect(Scheduler).toFlushAndYield(['A']);
expect(ReactNoop.getChildren()).toEqual([span('A')]);
@ -2635,8 +2634,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
});
// Later we load the data.
Scheduler.unstable_advanceTime(3000);
await advanceTimers(3000);
await resolveText('B');
expect(Scheduler).toHaveYielded(['Promise resolved [B]']);
expect(Scheduler).toFlushAndYield(['B']);
expect(ReactNoop.getChildren()).toEqual([span('B')]);
@ -2754,12 +2752,14 @@ describe('ReactSuspenseWithNoopRenderer', () => {
);
});
// TODO: This test is specifically about avoided commits that suspend for a
// JND. We may remove this behavior.
it("suspended commit remains suspended even if there's another update at same expiration", async () => {
// Regression test
function App({text}) {
return (
<Suspense fallback="Loading...">
<AsyncText ms={2000} text={text} />
<AsyncText text={text} />
</Suspense>
);
}
@ -2768,34 +2768,28 @@ describe('ReactSuspenseWithNoopRenderer', () => {
await ReactNoop.act(async () => {
root.render(<App text="Initial" />);
});
expect(Scheduler).toHaveYielded(['Suspend! [Initial]']);
// Resolve initial render
await ReactNoop.act(async () => {
Scheduler.unstable_advanceTime(2000);
await advanceTimers(2000);
await resolveText('Initial');
});
expect(Scheduler).toHaveYielded([
'Suspend! [Initial]',
'Promise resolved [Initial]',
'Initial',
]);
expect(Scheduler).toHaveYielded(['Promise resolved [Initial]', 'Initial']);
expect(root).toMatchRenderedOutput(<span prop="Initial" />);
// Update. Since showing a fallback would hide content that's already
// visible, it should suspend for a bit without committing.
await ReactNoop.act(async () => {
// Update. Since showing a fallback would hide content that's already
// visible, it should suspend for a JND without committing.
root.render(<App text="First update" />);
expect(Scheduler).toFlushAndYield(['Suspend! [First update]']);
// Should not display a fallback
expect(root).toMatchRenderedOutput(<span prop="Initial" />);
});
// Update again. This should also suspend for a bit.
await ReactNoop.act(async () => {
// Update again. This should also suspend for a JND.
root.render(<App text="Second update" />);
expect(Scheduler).toFlushAndYield(['Suspend! [Second update]']);
// Should not display a fallback
expect(root).toMatchRenderedOutput(<span prop="Initial" />);
});
@ -3989,9 +3983,6 @@ describe('ReactSuspenseWithNoopRenderer', () => {
await ReactNoop.act(async () => {
root.render(<App show={true} />);
});
// TODO: `act` should have already flushed the placeholder, so this
// runAllTimers call should be unnecessary.
jest.runAllTimers();
expect(Scheduler).toHaveYielded(['Suspend! [Async]', 'Loading...']);
expect(root).toMatchRenderedOutput(
<>

View File

@ -684,8 +684,9 @@ function unstable_concurrentAct(scope: () => Thenable<mixed> | void) {
}
function flushActWork(resolve, reject) {
// TODO: Run timers to flush suspended fallbacks
// jest.runOnlyPendingTimers();
// Flush suspended fallbacks
// $FlowFixMe: Flow doesn't know about global Jest object
jest.runOnlyPendingTimers();
enqueueTask(() => {
try {
const didFlushWork = Scheduler.unstable_flushAllWithoutAsserting();

View File

@ -42,6 +42,7 @@ module.exports = {
// jest
expect: true,
jest: true,
},
parserOptions: {
ecmaVersion: 5,

View File

@ -42,6 +42,7 @@ module.exports = {
// jest
expect: true,
jest: true,
},
parserOptions: {
ecmaVersion: 2015,

View File

@ -36,6 +36,9 @@ module.exports = {
// Flight
Uint8Array: true,
Promise: true,
// jest
jest: true,
},
parserOptions: {
ecmaVersion: 5,

View File

@ -31,6 +31,9 @@ module.exports = {
ArrayBuffer: true,
TaskController: true,
// jest
jest: true,
},
parserOptions: {
ecmaVersion: 5,

View File

@ -43,6 +43,9 @@ module.exports = {
// Flight Webpack
__webpack_chunk_load__: true,
__webpack_require__: true,
// jest
jest: true,
},
parserOptions: {
ecmaVersion: 5,