Skip to content

Commit 21cc354

Browse files
committed
Expand act warning to include Suspense resolutions
For the same reason we warn when an update is not wrapped with act, we should warn if a Suspense promise resolution is not wrapped with act. Both "pings" and "retries". Legacy root behavior is unchanged.
1 parent 986274e commit 21cc354

File tree

5 files changed

+243
-9
lines changed

5 files changed

+243
-9
lines changed

packages/react-devtools-scheduling-profiler/src/import-worker/__tests__/preprocessData-test.internal.js

+3-3
Original file line numberDiff line numberDiff line change
@@ -1231,7 +1231,7 @@ describe('preprocessData', () => {
12311231

12321232
testMarks.push(...createUserTimingData(clearedMarks));
12331233

1234-
const data = await preprocessData(testMarks);
1234+
const data = await act(() => preprocessData(testMarks));
12351235
expect(data.suspenseEvents).toHaveLength(1);
12361236
expect(data.suspenseEvents[0].promiseName).toBe('Testing displayName');
12371237
}
@@ -1682,7 +1682,7 @@ describe('preprocessData', () => {
16821682

16831683
testMarks.push(...createUserTimingData(clearedMarks));
16841684

1685-
const data = await preprocessData(testMarks);
1685+
const data = await act(() => preprocessData(testMarks));
16861686
expect(data.suspenseEvents).toHaveLength(1);
16871687
expect(data.suspenseEvents[0].warning).toMatchInlineSnapshot(
16881688
`"A component suspended during an update which caused a fallback to be shown. Consider using the Transition API to avoid hiding components after they've been mounted."`,
@@ -1740,7 +1740,7 @@ describe('preprocessData', () => {
17401740

17411741
testMarks.push(...createUserTimingData(clearedMarks));
17421742

1743-
const data = await preprocessData(testMarks);
1743+
const data = await act(() => preprocessData(testMarks));
17441744
expect(data.suspenseEvents).toHaveLength(1);
17451745
expect(data.suspenseEvents[0].warning).toBe(null);
17461746
}

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

+26
Original file line numberDiff line numberDiff line change
@@ -2368,6 +2368,8 @@ export function pingSuspendedRoot(
23682368
const eventTime = requestEventTime();
23692369
markRootPinged(root, pingedLanes, eventTime);
23702370

2371+
warnIfSuspenseResolutionNotWrappedWithActDEV(root);
2372+
23712373
if (
23722374
workInProgressRoot === root &&
23732375
isSubsetOfLanes(workInProgressRootRenderLanes, pingedLanes)
@@ -2902,3 +2904,27 @@ function warnIfUpdatesNotWrappedWithActDEV(fiber: Fiber): void {
29022904
}
29032905
}
29042906
}
2907+
2908+
function warnIfSuspenseResolutionNotWrappedWithActDEV(root: FiberRoot): void {
2909+
if (__DEV__) {
2910+
if (
2911+
root.tag !== LegacyRoot &&
2912+
isConcurrentActEnvironment() &&
2913+
ReactCurrentActQueue.current === null
2914+
) {
2915+
console.error(
2916+
'A suspended resource finished loading inside a test, but the event ' +
2917+
'was not wrapped in act(...).\n\n' +
2918+
'When testing, code that resolves suspended data should be wrapped ' +
2919+
'into act(...):\n\n' +
2920+
'act(() => {\n' +
2921+
' /* finish loading suspended data */\n' +
2922+
'});\n' +
2923+
'/* assert on the output */\n\n' +
2924+
"This ensures that you're testing the behavior the user would see " +
2925+
'in the browser.' +
2926+
' Learn more at https://reactjs.org/link/wrap-tests-with-act',
2927+
);
2928+
}
2929+
}
2930+
}

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

+26
Original file line numberDiff line numberDiff line change
@@ -2368,6 +2368,8 @@ export function pingSuspendedRoot(
23682368
const eventTime = requestEventTime();
23692369
markRootPinged(root, pingedLanes, eventTime);
23702370

2371+
warnIfSuspenseResolutionNotWrappedWithActDEV(root);
2372+
23712373
if (
23722374
workInProgressRoot === root &&
23732375
isSubsetOfLanes(workInProgressRootRenderLanes, pingedLanes)
@@ -2902,3 +2904,27 @@ function warnIfUpdatesNotWrappedWithActDEV(fiber: Fiber): void {
29022904
}
29032905
}
29042906
}
2907+
2908+
function warnIfSuspenseResolutionNotWrappedWithActDEV(root: FiberRoot): void {
2909+
if (__DEV__) {
2910+
if (
2911+
root.tag !== LegacyRoot &&
2912+
isConcurrentActEnvironment() &&
2913+
ReactCurrentActQueue.current === null
2914+
) {
2915+
console.error(
2916+
'A suspended resource finished loading inside a test, but the event ' +
2917+
'was not wrapped in act(...).\n\n' +
2918+
'When testing, code that resolves suspended data should be wrapped ' +
2919+
'into act(...):\n\n' +
2920+
'act(() => {\n' +
2921+
' /* finish loading suspended data */\n' +
2922+
'});\n' +
2923+
'/* assert on the output */\n\n' +
2924+
"This ensures that you're testing the behavior the user would see " +
2925+
'in the browser.' +
2926+
' Learn more at https://reactjs.org/link/wrap-tests-with-act',
2927+
);
2928+
}
2929+
}
2930+
}

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

+14-3
Original file line numberDiff line numberDiff line change
@@ -140,9 +140,20 @@ describe('DebugTracing', () => {
140140

141141
// @gate experimental && build === 'development' && enableDebugTracing
142142
it('should log concurrent render with suspense', async () => {
143-
const fakeSuspensePromise = Promise.resolve(true);
143+
let isResolved = false;
144+
let resolveFakeSuspensePromise;
145+
const fakeSuspensePromise = new Promise(resolve => {
146+
resolveFakeSuspensePromise = () => {
147+
resolve();
148+
isResolved = true;
149+
};
150+
});
151+
144152
function Example() {
145-
throw fakeSuspensePromise;
153+
if (!isResolved) {
154+
throw fakeSuspensePromise;
155+
}
156+
return null;
146157
}
147158

148159
ReactTestRenderer.act(() =>
@@ -164,7 +175,7 @@ describe('DebugTracing', () => {
164175

165176
logs.splice(0);
166177

167-
await fakeSuspensePromise;
178+
await ReactTestRenderer.act(async () => await resolveFakeSuspensePromise());
168179
expect(logs).toEqual(['log: ⚛️ Example resolved']);
169180
});
170181

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

+174-3
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ let Scheduler;
1212
let ReactNoop;
1313
let useState;
1414
let act;
15+
let Suspense;
16+
let startTransition;
17+
let getCacheForType;
18+
let caches;
1519

1620
// These tests are mostly concerned with concurrent roots. The legacy root
1721
// behavior is covered by other older test suites and is unchanged from
@@ -24,11 +28,110 @@ describe('act warnings', () => {
2428
ReactNoop = require('react-noop-renderer');
2529
act = React.unstable_act;
2630
useState = React.useState;
31+
Suspense = React.Suspense;
32+
startTransition = React.startTransition;
33+
getCacheForType = React.unstable_getCacheForType;
34+
caches = [];
2735
});
2836

29-
function Text(props) {
30-
Scheduler.unstable_yieldValue(props.text);
31-
return props.text;
37+
function createTextCache() {
38+
const data = new Map();
39+
const version = caches.length + 1;
40+
const cache = {
41+
version,
42+
data,
43+
resolve(text) {
44+
const record = data.get(text);
45+
if (record === undefined) {
46+
const newRecord = {
47+
status: 'resolved',
48+
value: text,
49+
};
50+
data.set(text, newRecord);
51+
} else if (record.status === 'pending') {
52+
const thenable = record.value;
53+
record.status = 'resolved';
54+
record.value = text;
55+
thenable.pings.forEach(t => t());
56+
}
57+
},
58+
reject(text, error) {
59+
const record = data.get(text);
60+
if (record === undefined) {
61+
const newRecord = {
62+
status: 'rejected',
63+
value: error,
64+
};
65+
data.set(text, newRecord);
66+
} else if (record.status === 'pending') {
67+
const thenable = record.value;
68+
record.status = 'rejected';
69+
record.value = error;
70+
thenable.pings.forEach(t => t());
71+
}
72+
},
73+
};
74+
caches.push(cache);
75+
return cache;
76+
}
77+
78+
function readText(text) {
79+
const textCache = getCacheForType(createTextCache);
80+
const record = textCache.data.get(text);
81+
if (record !== undefined) {
82+
switch (record.status) {
83+
case 'pending':
84+
Scheduler.unstable_yieldValue(`Suspend! [${text}]`);
85+
throw record.value;
86+
case 'rejected':
87+
Scheduler.unstable_yieldValue(`Error! [${text}]`);
88+
throw record.value;
89+
case 'resolved':
90+
return textCache.version;
91+
}
92+
} else {
93+
Scheduler.unstable_yieldValue(`Suspend! [${text}]`);
94+
95+
const thenable = {
96+
pings: [],
97+
then(resolve) {
98+
if (newRecord.status === 'pending') {
99+
thenable.pings.push(resolve);
100+
} else {
101+
Promise.resolve().then(() => resolve(newRecord.value));
102+
}
103+
},
104+
};
105+
106+
const newRecord = {
107+
status: 'pending',
108+
value: thenable,
109+
};
110+
textCache.data.set(text, newRecord);
111+
112+
throw thenable;
113+
}
114+
}
115+
116+
function Text({text}) {
117+
Scheduler.unstable_yieldValue(text);
118+
return text;
119+
}
120+
121+
function AsyncText({text}) {
122+
readText(text);
123+
Scheduler.unstable_yieldValue(text);
124+
return text;
125+
}
126+
127+
function resolveText(text) {
128+
if (caches.length === 0) {
129+
throw Error('Cache does not exist.');
130+
} else {
131+
// Resolve the most recently created cache. An older cache can by
132+
// resolved with `caches[index].resolve(text)`.
133+
caches[caches.length - 1].resolve(text);
134+
}
32135
}
33136

34137
function withActEnvironment(value, scope) {
@@ -187,4 +290,72 @@ describe('act warnings', () => {
187290
expect(root).toMatchRenderedOutput('1');
188291
});
189292
});
293+
294+
// @gate __DEV__
295+
// @gate enableCache
296+
test('warns if Suspense retry is not wrapped', () => {
297+
function App() {
298+
return (
299+
<Suspense fallback={<Text text="Loading..." />}>
300+
<AsyncText text="Async" />
301+
</Suspense>
302+
);
303+
}
304+
305+
withActEnvironment(true, () => {
306+
const root = ReactNoop.createRoot();
307+
act(() => {
308+
root.render(<App />);
309+
});
310+
expect(Scheduler).toHaveYielded(['Suspend! [Async]', 'Loading...']);
311+
expect(root).toMatchRenderedOutput('Loading...');
312+
313+
// This is a retry, not a ping, because we already showed a fallback.
314+
expect(() =>
315+
resolveText('Async'),
316+
).toErrorDev(
317+
'A suspended resource finished loading inside a test, but the event ' +
318+
'was not wrapped in act(...)',
319+
{withoutStack: true},
320+
);
321+
});
322+
});
323+
324+
// @gate __DEV__
325+
// @gate enableCache
326+
test('warns if Suspense ping is not wrapped', () => {
327+
function App({showMore}) {
328+
return (
329+
<Suspense fallback={<Text text="Loading..." />}>
330+
{showMore ? <AsyncText text="Async" /> : <Text text="(empty)" />}
331+
</Suspense>
332+
);
333+
}
334+
335+
withActEnvironment(true, () => {
336+
const root = ReactNoop.createRoot();
337+
act(() => {
338+
root.render(<App showMore={false} />);
339+
});
340+
expect(Scheduler).toHaveYielded(['(empty)']);
341+
expect(root).toMatchRenderedOutput('(empty)');
342+
343+
act(() => {
344+
startTransition(() => {
345+
root.render(<App showMore={true} />);
346+
});
347+
});
348+
expect(Scheduler).toHaveYielded(['Suspend! [Async]', 'Loading...']);
349+
expect(root).toMatchRenderedOutput('(empty)');
350+
351+
// This is a ping, not a retry, because no fallback is showing.
352+
expect(() =>
353+
resolveText('Async'),
354+
).toErrorDev(
355+
'A suspended resource finished loading inside a test, but the event ' +
356+
'was not wrapped in act(...)',
357+
{withoutStack: true},
358+
);
359+
});
360+
});
190361
});

0 commit comments

Comments
 (0)