Skip to content

Commit e1fec84

Browse files
committed
Support use in act testing API
`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 returning by `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.
1 parent 9cdf8a9 commit e1fec84

File tree

5 files changed

+382
-118
lines changed

5 files changed

+382
-118
lines changed

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

Lines changed: 7 additions & 0 deletions
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

Lines changed: 7 additions & 0 deletions
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

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,25 @@
1010
// sanity tests for act()
1111

1212
let React;
13+
let Scheduler;
1314
let ReactNoop;
1415
let act;
16+
let use;
17+
let Suspense;
1518
let DiscreteEventPriority;
19+
let startTransition;
1620

1721
describe('isomorphic act()', () => {
1822
beforeEach(() => {
1923
React = require('react');
24+
Scheduler = require('scheduler');
2025
ReactNoop = require('react-noop-renderer');
2126
DiscreteEventPriority = require('react-reconciler/constants')
2227
.DiscreteEventPriority;
2328
act = React.unstable_act;
29+
use = React.experimental_use;
30+
Suspense = React.Suspense;
31+
startTransition = React.startTransition;
2432
});
2533

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

0 commit comments

Comments
 (0)