Skip to content

Commit 8bd8f01

Browse files
committed
[Fix] Errors should not "unsuspend" a transition
If an error is thrown during a transition where we would have otherwise suspended without showing a fallback (i.e. during a refresh), we should still suspend. The current behavior is that the error will force the fallback to appear, even if it's completely unrelated to the component that errored, which breaks the contract of `startTransition`.
1 parent 1c73cee commit 8bd8f01

File tree

3 files changed

+304
-4
lines changed

3 files changed

+304
-4
lines changed

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

+3-2
Original file line numberDiff line numberDiff line change
@@ -1445,7 +1445,8 @@ export function renderDidSuspend(): void {
14451445
export function renderDidSuspendDelayIfPossible(): void {
14461446
if (
14471447
workInProgressRootExitStatus === RootIncomplete ||
1448-
workInProgressRootExitStatus === RootSuspended
1448+
workInProgressRootExitStatus === RootSuspended ||
1449+
workInProgressRootExitStatus === RootErrored
14491450
) {
14501451
workInProgressRootExitStatus = RootSuspendedWithDelay;
14511452
}
@@ -1469,7 +1470,7 @@ export function renderDidSuspendDelayIfPossible(): void {
14691470
}
14701471

14711472
export function renderDidError() {
1472-
if (workInProgressRootExitStatus !== RootCompleted) {
1473+
if (workInProgressRootExitStatus !== RootSuspendedWithDelay) {
14731474
workInProgressRootExitStatus = RootErrored;
14741475
}
14751476
}

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

+3-2
Original file line numberDiff line numberDiff line change
@@ -1445,7 +1445,8 @@ export function renderDidSuspend(): void {
14451445
export function renderDidSuspendDelayIfPossible(): void {
14461446
if (
14471447
workInProgressRootExitStatus === RootIncomplete ||
1448-
workInProgressRootExitStatus === RootSuspended
1448+
workInProgressRootExitStatus === RootSuspended ||
1449+
workInProgressRootExitStatus === RootErrored
14491450
) {
14501451
workInProgressRootExitStatus = RootSuspendedWithDelay;
14511452
}
@@ -1469,7 +1470,7 @@ export function renderDidSuspendDelayIfPossible(): void {
14691470
}
14701471

14711472
export function renderDidError() {
1472-
if (workInProgressRootExitStatus !== RootCompleted) {
1473+
if (workInProgressRootExitStatus !== RootSuspendedWithDelay) {
14731474
workInProgressRootExitStatus = RootErrored;
14741475
}
14751476
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
let React;
2+
let ReactNoop;
3+
let Scheduler;
4+
let act;
5+
let Suspense;
6+
let getCacheForType;
7+
let startTransition;
8+
9+
let caches;
10+
let seededCache;
11+
12+
describe('ReactConcurrentErrorRecovery', () => {
13+
beforeEach(() => {
14+
jest.resetModules();
15+
16+
React = require('react');
17+
ReactNoop = require('react-noop-renderer');
18+
Scheduler = require('scheduler');
19+
act = require('jest-react').act;
20+
Suspense = React.Suspense;
21+
startTransition = React.startTransition;
22+
23+
getCacheForType = React.unstable_getCacheForType;
24+
25+
caches = [];
26+
seededCache = null;
27+
});
28+
29+
function createTextCache() {
30+
if (seededCache !== null) {
31+
// Trick to seed a cache before it exists.
32+
// TODO: Need a built-in API to seed data before the initial render (i.e.
33+
// not a refresh because nothing has mounted yet).
34+
const cache = seededCache;
35+
seededCache = null;
36+
return cache;
37+
}
38+
39+
const data = new Map();
40+
const version = caches.length + 1;
41+
const cache = {
42+
version,
43+
data,
44+
resolve(text) {
45+
const record = data.get(text);
46+
if (record === undefined) {
47+
const newRecord = {
48+
status: 'resolved',
49+
value: text,
50+
};
51+
data.set(text, newRecord);
52+
} else if (record.status === 'pending') {
53+
const thenable = record.value;
54+
record.status = 'resolved';
55+
record.value = text;
56+
thenable.pings.forEach(t => t());
57+
}
58+
},
59+
reject(text, error) {
60+
const record = data.get(text);
61+
if (record === undefined) {
62+
const newRecord = {
63+
status: 'rejected',
64+
value: error,
65+
};
66+
data.set(text, newRecord);
67+
} else if (record.status === 'pending') {
68+
const thenable = record.value;
69+
record.status = 'rejected';
70+
record.value = error;
71+
thenable.pings.forEach(t => t());
72+
}
73+
},
74+
};
75+
caches.push(cache);
76+
return cache;
77+
}
78+
79+
function readText(text) {
80+
const textCache = getCacheForType(createTextCache);
81+
const record = textCache.data.get(text);
82+
if (record !== undefined) {
83+
switch (record.status) {
84+
case 'pending':
85+
Scheduler.unstable_yieldValue(`Suspend! [${text}]`);
86+
throw record.value;
87+
case 'rejected':
88+
Scheduler.unstable_yieldValue(`Error! [${text}]`);
89+
throw record.value;
90+
case 'resolved':
91+
return textCache.version;
92+
}
93+
} else {
94+
Scheduler.unstable_yieldValue(`Suspend! [${text}]`);
95+
96+
const thenable = {
97+
pings: [],
98+
then(resolve) {
99+
if (newRecord.status === 'pending') {
100+
thenable.pings.push(resolve);
101+
} else {
102+
Promise.resolve().then(() => resolve(newRecord.value));
103+
}
104+
},
105+
};
106+
107+
const newRecord = {
108+
status: 'pending',
109+
value: thenable,
110+
};
111+
textCache.data.set(text, newRecord);
112+
113+
throw thenable;
114+
}
115+
}
116+
117+
function Text({text}) {
118+
Scheduler.unstable_yieldValue(text);
119+
return text;
120+
}
121+
122+
function AsyncText({text, showVersion}) {
123+
const version = readText(text);
124+
const fullText = showVersion ? `${text} [v${version}]` : text;
125+
Scheduler.unstable_yieldValue(fullText);
126+
return fullText;
127+
}
128+
129+
function seedNextTextCache(text) {
130+
if (seededCache === null) {
131+
seededCache = createTextCache();
132+
}
133+
seededCache.resolve(text);
134+
}
135+
136+
function resolveMostRecentTextCache(text) {
137+
if (caches.length === 0) {
138+
throw Error('Cache does not exist.');
139+
} else {
140+
// Resolve the most recently created cache. An older cache can by
141+
// resolved with `caches[index].resolve(text)`.
142+
caches[caches.length - 1].resolve(text);
143+
}
144+
}
145+
146+
const resolveText = resolveMostRecentTextCache;
147+
148+
function rejectMostRecentTextCache(text, error) {
149+
if (caches.length === 0) {
150+
throw Error('Cache does not exist.');
151+
} else {
152+
// Resolve the most recently created cache. An older cache can by
153+
// resolved with `caches[index].reject(text, error)`.
154+
caches[caches.length - 1].reject(text, error);
155+
}
156+
}
157+
158+
const rejectText = rejectMostRecentTextCache;
159+
160+
// @gate enableCache
161+
test('errors during a refresh transition should not force fallbacks to display', async () => {
162+
class ErrorBoundary extends React.Component {
163+
state = {error: null};
164+
static getDerivedStateFromError(error) {
165+
return {error};
166+
}
167+
render() {
168+
if (this.state.error !== null) {
169+
return <Text text={this.state.error.message} />;
170+
}
171+
return this.props.children;
172+
}
173+
}
174+
175+
function App({step}) {
176+
return (
177+
<>
178+
<Suspense fallback={<Text text="Loading..." />}>
179+
<ErrorBoundary>
180+
<AsyncText text={'A' + step} />
181+
</ErrorBoundary>
182+
</Suspense>
183+
<Suspense fallback={<Text text="Loading..." />}>
184+
<ErrorBoundary>
185+
<AsyncText text={'B' + step} />
186+
</ErrorBoundary>
187+
</Suspense>
188+
<Suspense fallback={<Text text="Loading..." />}>
189+
<ErrorBoundary>
190+
<AsyncText text={'C' + step} />
191+
</ErrorBoundary>
192+
</Suspense>
193+
</>
194+
);
195+
}
196+
197+
// Initial render
198+
const root = ReactNoop.createRoot();
199+
seedNextTextCache('A1');
200+
seedNextTextCache('B1');
201+
seedNextTextCache('C1');
202+
await act(async () => {
203+
root.render(<App step={1} />);
204+
});
205+
expect(Scheduler).toHaveYielded(['A1', 'B1', 'C1']);
206+
expect(root).toMatchRenderedOutput('A1B1C1');
207+
208+
// Start a refresh transition
209+
await act(async () => {
210+
startTransition(() => {
211+
root.render(<App step={2} />);
212+
});
213+
});
214+
expect(Scheduler).toHaveYielded([
215+
'Suspend! [A2]',
216+
'Loading...',
217+
'Suspend! [B2]',
218+
'Loading...',
219+
'Suspend! [C2]',
220+
'Loading...',
221+
]);
222+
// Because this is a refresh, we don't switch to a fallback
223+
expect(root).toMatchRenderedOutput('A1B1C1');
224+
225+
// B fails to load.
226+
await act(async () => {
227+
rejectText('B2', new Error('Oops!'));
228+
});
229+
230+
// Because we're still suspended on A and C, we can't show an error
231+
// boundary. We should wait for A to resolve.
232+
if (gate(flags => flags.replayFailedUnitOfWorkWithInvokeGuardedCallback)) {
233+
expect(Scheduler).toHaveYielded([
234+
'Suspend! [A2]',
235+
'Loading...',
236+
237+
'Error! [B2]',
238+
// This extra log happens when we replay the error
239+
// in invokeGuardedCallback
240+
'Error! [B2]',
241+
'Oops!',
242+
243+
'Suspend! [C2]',
244+
'Loading...',
245+
]);
246+
} else {
247+
expect(Scheduler).toHaveYielded([
248+
'Suspend! [A2]',
249+
'Loading...',
250+
'Error! [B2]',
251+
'Oops!',
252+
'Suspend! [C2]',
253+
'Loading...',
254+
]);
255+
}
256+
// Remain on previous screen.
257+
expect(root).toMatchRenderedOutput('A1B1C1');
258+
259+
// A and C finishes loading.
260+
await act(async () => {
261+
resolveText('A2');
262+
resolveText('C2');
263+
});
264+
if (gate(flags => flags.replayFailedUnitOfWorkWithInvokeGuardedCallback)) {
265+
expect(Scheduler).toHaveYielded([
266+
'A2',
267+
'Error! [B2]',
268+
// This extra log happens when we replay the error
269+
// in invokeGuardedCallback
270+
'Error! [B2]',
271+
'Oops!',
272+
'C2',
273+
274+
'A2',
275+
'Error! [B2]',
276+
// This extra log happens when we replay the error
277+
// in invokeGuardedCallback
278+
'Error! [B2]',
279+
'Oops!',
280+
'C2',
281+
]);
282+
} else {
283+
expect(Scheduler).toHaveYielded([
284+
'A2',
285+
'Error! [B2]',
286+
'Oops!',
287+
'C2',
288+
289+
'A2',
290+
'Error! [B2]',
291+
'Oops!',
292+
'C2',
293+
]);
294+
}
295+
// Now we can show the error boundary that's wrapped around B.
296+
expect(root).toMatchRenderedOutput('A2Oops!C2');
297+
});
298+
});

0 commit comments

Comments
 (0)