Skip to content

Commit c635807

Browse files
authored
Support use in act testing API (#25523)
`use` can avoid suspending on already resolved data by yielding to microtasks. In a real, browser environment, we do this by scheduling a platform task (i.e. postTask). In a test environment, tasks are scheduled on a special internal queue so that they can be flushed by the `act` testing API. So we need to add support for this in `act`. This behavior only works if you `await` the thenable returned by the `act` call. We currently do not require that users do this. So I added a warning, but it only fires if `use` was called. The old Suspense pattern will not trigger a warning. This is to avoid breaking existing tests that use Suspense. The implementation of `act` has gotten extremely complicated because of the subtle changes in behavior over the years, and our commitment to maintaining backwards compatibility. We really should consider being more restrictive in a future major release. The changes are a bit confusing so I did my best to add inline comments explaining how it works. ## Test plan I ran this against Facebook's internal Jest test suite to confirm nothing broke
1 parent 65e32e5 commit c635807

File tree

5 files changed

+380
-118
lines changed

5 files changed

+380
-118
lines changed

packages/react-reconciler/src/ReactFiberWakeable.new.js

+7
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ import type {
1515
RejectedThenable,
1616
} from 'shared/ReactTypes';
1717

18+
import ReactSharedInternals from 'shared/ReactSharedInternals';
19+
const {ReactCurrentActQueue} = ReactSharedInternals;
20+
1821
let suspendedThenable: Thenable<mixed> | null = null;
1922
let adHocSuspendCount: number = 0;
2023

@@ -124,6 +127,10 @@ export function trackUsedThenable<T>(thenable: Thenable<T>, index: number) {
124127
}
125128
usedThenables[index] = thenable;
126129
lastUsedThenable = thenable;
130+
131+
if (__DEV__ && ReactCurrentActQueue.current !== null) {
132+
ReactCurrentActQueue.didUsePromise = true;
133+
}
127134
}
128135

129136
export function getPreviouslyUsedThenableAtIndex<T>(

packages/react-reconciler/src/ReactFiberWakeable.old.js

+7
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ import type {
1515
RejectedThenable,
1616
} from 'shared/ReactTypes';
1717

18+
import ReactSharedInternals from 'shared/ReactSharedInternals';
19+
const {ReactCurrentActQueue} = ReactSharedInternals;
20+
1821
let suspendedThenable: Thenable<mixed> | null = null;
1922
let adHocSuspendCount: number = 0;
2023

@@ -124,6 +127,10 @@ export function trackUsedThenable<T>(thenable: Thenable<T>, index: number) {
124127
}
125128
usedThenables[index] = thenable;
126129
lastUsedThenable = thenable;
130+
131+
if (__DEV__ && ReactCurrentActQueue.current !== null) {
132+
ReactCurrentActQueue.didUsePromise = true;
133+
}
127134
}
128135

129136
export function getPreviouslyUsedThenableAtIndex<T>(

packages/react-reconciler/src/__tests__/ReactIsomorphicAct-test.js

+157
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,22 @@
1212
let React;
1313
let ReactNoop;
1414
let act;
15+
let use;
16+
let Suspense;
1517
let DiscreteEventPriority;
18+
let startTransition;
1619

1720
describe('isomorphic act()', () => {
1821
beforeEach(() => {
1922
React = require('react');
23+
2024
ReactNoop = require('react-noop-renderer');
2125
DiscreteEventPriority = require('react-reconciler/constants')
2226
.DiscreteEventPriority;
2327
act = React.unstable_act;
28+
use = React.experimental_use;
29+
Suspense = React.Suspense;
30+
startTransition = React.startTransition;
2431
});
2532

2633
beforeEach(() => {
@@ -133,4 +140,154 @@ describe('isomorphic act()', () => {
133140
expect(root).toMatchRenderedOutput('C');
134141
});
135142
});
143+
144+
// @gate __DEV__
145+
// @gate enableUseHook
146+
test('unwraps promises by yielding to microtasks (async act scope)', async () => {
147+
const promise = Promise.resolve('Async');
148+
149+
function Fallback() {
150+
throw new Error('Fallback should never be rendered');
151+
}
152+
153+
function App() {
154+
return use(promise);
155+
}
156+
157+
const root = ReactNoop.createRoot();
158+
await act(async () => {
159+
startTransition(() => {
160+
root.render(
161+
<Suspense fallback={<Fallback />}>
162+
<App />
163+
</Suspense>,
164+
);
165+
});
166+
});
167+
expect(root).toMatchRenderedOutput('Async');
168+
});
169+
170+
// @gate __DEV__
171+
// @gate enableUseHook
172+
test('unwraps promises by yielding to microtasks (non-async act scope)', async () => {
173+
const promise = Promise.resolve('Async');
174+
175+
function Fallback() {
176+
throw new Error('Fallback should never be rendered');
177+
}
178+
179+
function App() {
180+
return use(promise);
181+
}
182+
183+
const root = ReactNoop.createRoot();
184+
185+
// Note that the scope function is not an async function
186+
await act(() => {
187+
startTransition(() => {
188+
root.render(
189+
<Suspense fallback={<Fallback />}>
190+
<App />
191+
</Suspense>,
192+
);
193+
});
194+
});
195+
expect(root).toMatchRenderedOutput('Async');
196+
});
197+
198+
// @gate __DEV__
199+
// @gate enableUseHook
200+
test('warns if a promise is used in a non-awaited `act` scope', async () => {
201+
const promise = new Promise(() => {});
202+
203+
function Fallback() {
204+
throw new Error('Fallback should never be rendered');
205+
}
206+
207+
function App() {
208+
return use(promise);
209+
}
210+
211+
spyOnDev(console, 'error');
212+
const root = ReactNoop.createRoot();
213+
act(() => {
214+
startTransition(() => {
215+
root.render(
216+
<Suspense fallback={<Fallback />}>
217+
<App />
218+
</Suspense>,
219+
);
220+
});
221+
});
222+
223+
// `act` warns after a few microtasks, instead of a macrotask, so that it's
224+
// more likely to be attributed to the correct test case.
225+
//
226+
// The exact number of microtasks is an implementation detail; just needs
227+
// to happen when the microtask queue is flushed.
228+
await null;
229+
await null;
230+
await null;
231+
232+
expect(console.error.calls.count()).toBe(1);
233+
expect(console.error.calls.argsFor(0)[0]).toContain(
234+
'Warning: A component suspended inside an `act` scope, but the `act` ' +
235+
'call was not awaited. When testing React components that ' +
236+
'depend on asynchronous data, you must await the result:\n\n' +
237+
'await act(() => ...)',
238+
);
239+
});
240+
241+
// @gate __DEV__
242+
test('does not warn when suspending via legacy `throw` API in non-awaited `act` scope', async () => {
243+
let didResolve = false;
244+
let resolvePromise;
245+
const promise = new Promise(r => {
246+
resolvePromise = () => {
247+
didResolve = true;
248+
r();
249+
};
250+
});
251+
252+
function Fallback() {
253+
return 'Loading...';
254+
}
255+
256+
function App() {
257+
if (!didResolve) {
258+
throw promise;
259+
}
260+
return 'Async';
261+
}
262+
263+
spyOnDev(console, 'error');
264+
const root = ReactNoop.createRoot();
265+
act(() => {
266+
startTransition(() => {
267+
root.render(
268+
<Suspense fallback={<Fallback />}>
269+
<App />
270+
</Suspense>,
271+
);
272+
});
273+
});
274+
expect(root).toMatchRenderedOutput('Loading...');
275+
276+
// `act` warns after a few microtasks, instead of a macrotask, so that it's
277+
// more likely to be attributed to the correct test case.
278+
//
279+
// The exact number of microtasks is an implementation detail; just needs
280+
// to happen when the microtask queue is flushed.
281+
await null;
282+
await null;
283+
await null;
284+
285+
expect(console.error.calls.count()).toBe(0);
286+
287+
// Finish loading the data
288+
await act(async () => {
289+
resolvePromise();
290+
});
291+
expect(root).toMatchRenderedOutput('Async');
292+
});
136293
});

0 commit comments

Comments
 (0)