Skip to content

Commit 29025d8

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 c21e9c0 commit 29025d8

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

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

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

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

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -139,9 +139,20 @@ describe('DebugTracing', () => {
139139

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

147158
ReactTestRenderer.act(() =>
@@ -163,7 +174,7 @@ describe('DebugTracing', () => {
163174

164175
logs.splice(0);
165176

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

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

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

0 commit comments

Comments
 (0)