Skip to content

Commit dd480ef

Browse files
authored
Fix: Stylesheet in error UI suspends indefinitely (#27265)
This fixes the regression test added in the previous commit. The "Suspensey commit" implementation relies on the `shouldRemainOnPreviousScreen` function to determine whether to 1) suspend the commit 2) activate a parent fallback and schedule a retry. The issue was that we were sometimes attempting option 2 even when there was no parent fallback. Part of the reason this bug landed is due to how `throwException` is structured. In the case of Suspensey commits, we pass a special "noop" thenable to `throwException` as a way to trigger the Suspense path. This special thenable must never have a listener attached to it. This is not a great way to structure the logic, it's just a consequence of how the code evolved over time. We should refactor it into multiple functions so we can trigger a fallback directly without having to check the type. In the meantime, I added an internal warning to help detect similar mistakes in the future.
1 parent e76a5ac commit dd480ef

File tree

4 files changed

+51
-40
lines changed

4 files changed

+51
-40
lines changed

packages/react-dom/src/__tests__/ReactDOMFloat-test.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3385,7 +3385,6 @@ body {
33853385
);
33863386
});
33873387

3388-
// @gate FIXME
33893388
it('loading a stylesheet as part of an error boundary UI, during initial render', async () => {
33903389
class ErrorBoundary extends React.Component {
33913390
state = {error: null};

packages/react-reconciler/src/ReactFiberThenable.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,16 @@ export const SuspenseyCommitException: mixed = new Error(
4242
// TODO: It would be better to refactor throwException into multiple functions
4343
// so we can trigger a fallback directly without having to check the type. But
4444
// for now this will do.
45-
export const noopSuspenseyCommitThenable = {then() {}};
45+
export const noopSuspenseyCommitThenable = {
46+
then() {
47+
if (__DEV__) {
48+
console.error(
49+
'Internal React error: A listener was unexpectedly attached to a ' +
50+
'"noop" thenable. This is a bug in React. Please file an issue.',
51+
);
52+
}
53+
},
54+
};
4655

4756
export function createThenableState(): ThenableState {
4857
// The ThenableState is created the first time a component suspends. If it

packages/react-reconciler/src/ReactFiberThrow.js

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -438,8 +438,15 @@ function throwException(
438438
} else {
439439
retryQueue.add(wakeable);
440440
}
441+
442+
// We only attach ping listeners in concurrent mode. Legacy
443+
// Suspense always commits fallbacks synchronously, so there are
444+
// no pings.
445+
if (suspenseBoundary.mode & ConcurrentMode) {
446+
attachPingListener(root, wakeable, rootRenderLanes);
447+
}
441448
}
442-
break;
449+
return;
443450
}
444451
case OffscreenComponent: {
445452
if (suspenseBoundary.mode & ConcurrentMode) {
@@ -466,24 +473,17 @@ function throwException(
466473
retryQueue.add(wakeable);
467474
}
468475
}
476+
477+
attachPingListener(root, wakeable, rootRenderLanes);
469478
}
470-
break;
479+
return;
471480
}
472-
// Fall through
473-
}
474-
default: {
475-
throw new Error(
476-
`Unexpected Suspense handler tag (${suspenseBoundary.tag}). This ` +
477-
'is a bug in React.',
478-
);
479481
}
480482
}
481-
// We only attach ping listeners in concurrent mode. Legacy Suspense always
482-
// commits fallbacks synchronously, so there are no pings.
483-
if (suspenseBoundary.mode & ConcurrentMode) {
484-
attachPingListener(root, wakeable, rootRenderLanes);
485-
}
486-
return;
483+
throw new Error(
484+
`Unexpected Suspense handler tag (${suspenseBoundary.tag}). This ` +
485+
'is a bug in React.',
486+
);
487487
} else {
488488
// No boundary was found. Unless this is a sync update, this is OK.
489489
// We can suspend and wait for more data to arrive.

packages/react-reconciler/src/ReactFiberWorkLoop.js

Lines changed: 26 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1687,6 +1687,16 @@ export function shouldRemainOnPreviousScreen(): boolean {
16871687
// takes into account both the priority of render and also whether showing a
16881688
// fallback would produce a desirable user experience.
16891689

1690+
const handler = getSuspenseHandler();
1691+
if (handler === null) {
1692+
// There's no Suspense boundary that can provide a fallback. We have no
1693+
// choice but to remain on the previous screen.
1694+
// NOTE: We do this even for sync updates, for lack of any better option. In
1695+
// the future, we may change how we handle this, like by putting the whole
1696+
// root into a "detached" mode.
1697+
return true;
1698+
}
1699+
16901700
// TODO: Once `use` has fully replaced the `throw promise` pattern, we should
16911701
// be able to remove the equivalent check in finishConcurrentRender, and rely
16921702
// just on this one.
@@ -1705,29 +1715,22 @@ export function shouldRemainOnPreviousScreen(): boolean {
17051715
}
17061716
}
17071717

1708-
const handler = getSuspenseHandler();
1709-
if (handler === null) {
1710-
// TODO: We should support suspending in the case where there's no
1711-
// parent Suspense boundary, even outside a transition. Somehow. Otherwise,
1712-
// an uncached promise can fall into an infinite loop.
1713-
} else {
1714-
if (
1715-
includesOnlyRetries(workInProgressRootRenderLanes) ||
1716-
// In this context, an OffscreenLane counts as a Retry
1717-
// TODO: It's become increasingly clear that Retries and Offscreen are
1718-
// deeply connected. They probably can be unified further.
1719-
includesSomeLane(workInProgressRootRenderLanes, OffscreenLane)
1720-
) {
1721-
// During a retry, we can suspend rendering if the nearest Suspense boundary
1722-
// is the boundary of the "shell", because we're guaranteed not to block
1723-
// any new content from appearing.
1724-
//
1725-
// The reason we must check if this is a retry is because it guarantees
1726-
// that suspending the work loop won't block an actual update, because
1727-
// retries don't "update" anything; they fill in fallbacks that were left
1728-
// behind by a previous transition.
1729-
return handler === getShellBoundary();
1730-
}
1718+
if (
1719+
includesOnlyRetries(workInProgressRootRenderLanes) ||
1720+
// In this context, an OffscreenLane counts as a Retry
1721+
// TODO: It's become increasingly clear that Retries and Offscreen are
1722+
// deeply connected. They probably can be unified further.
1723+
includesSomeLane(workInProgressRootRenderLanes, OffscreenLane)
1724+
) {
1725+
// During a retry, we can suspend rendering if the nearest Suspense boundary
1726+
// is the boundary of the "shell", because we're guaranteed not to block
1727+
// any new content from appearing.
1728+
//
1729+
// The reason we must check if this is a retry is because it guarantees
1730+
// that suspending the work loop won't block an actual update, because
1731+
// retries don't "update" anything; they fill in fallbacks that were left
1732+
// behind by a previous transition.
1733+
return handler === getShellBoundary();
17311734
}
17321735

17331736
// For all other Lanes besides Transitions and Retries, we should not wait

0 commit comments

Comments
 (0)