Skip to content

Commit

Permalink
Fix: Stylesheet in error UI suspends indefinitely (#27265)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
acdlite authored Aug 22, 2023
1 parent e76a5ac commit dd480ef
Show file tree
Hide file tree
Showing 4 changed files with 51 additions and 40 deletions.
1 change: 0 additions & 1 deletion packages/react-dom/src/__tests__/ReactDOMFloat-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3385,7 +3385,6 @@ body {
);
});

// @gate FIXME
it('loading a stylesheet as part of an error boundary UI, during initial render', async () => {
class ErrorBoundary extends React.Component {
state = {error: null};
Expand Down
11 changes: 10 additions & 1 deletion packages/react-reconciler/src/ReactFiberThenable.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,16 @@ export const SuspenseyCommitException: mixed = new Error(
// TODO: It would be better to refactor throwException into multiple functions
// so we can trigger a fallback directly without having to check the type. But
// for now this will do.
export const noopSuspenseyCommitThenable = {then() {}};
export const noopSuspenseyCommitThenable = {
then() {
if (__DEV__) {
console.error(
'Internal React error: A listener was unexpectedly attached to a ' +
'"noop" thenable. This is a bug in React. Please file an issue.',
);
}
},
};

export function createThenableState(): ThenableState {
// The ThenableState is created the first time a component suspends. If it
Expand Down
30 changes: 15 additions & 15 deletions packages/react-reconciler/src/ReactFiberThrow.js
Original file line number Diff line number Diff line change
Expand Up @@ -438,8 +438,15 @@ function throwException(
} else {
retryQueue.add(wakeable);
}

// We only attach ping listeners in concurrent mode. Legacy
// Suspense always commits fallbacks synchronously, so there are
// no pings.
if (suspenseBoundary.mode & ConcurrentMode) {
attachPingListener(root, wakeable, rootRenderLanes);
}
}
break;
return;
}
case OffscreenComponent: {
if (suspenseBoundary.mode & ConcurrentMode) {
Expand All @@ -466,24 +473,17 @@ function throwException(
retryQueue.add(wakeable);
}
}

attachPingListener(root, wakeable, rootRenderLanes);
}
break;
return;
}
// Fall through
}
default: {
throw new Error(
`Unexpected Suspense handler tag (${suspenseBoundary.tag}). This ` +
'is a bug in React.',
);
}
}
// We only attach ping listeners in concurrent mode. Legacy Suspense always
// commits fallbacks synchronously, so there are no pings.
if (suspenseBoundary.mode & ConcurrentMode) {
attachPingListener(root, wakeable, rootRenderLanes);
}
return;
throw new Error(
`Unexpected Suspense handler tag (${suspenseBoundary.tag}). This ` +
'is a bug in React.',
);
} else {
// No boundary was found. Unless this is a sync update, this is OK.
// We can suspend and wait for more data to arrive.
Expand Down
49 changes: 26 additions & 23 deletions packages/react-reconciler/src/ReactFiberWorkLoop.js
Original file line number Diff line number Diff line change
Expand Up @@ -1687,6 +1687,16 @@ export function shouldRemainOnPreviousScreen(): boolean {
// takes into account both the priority of render and also whether showing a
// fallback would produce a desirable user experience.

const handler = getSuspenseHandler();
if (handler === null) {
// There's no Suspense boundary that can provide a fallback. We have no
// choice but to remain on the previous screen.
// NOTE: We do this even for sync updates, for lack of any better option. In
// the future, we may change how we handle this, like by putting the whole
// root into a "detached" mode.
return true;
}

// TODO: Once `use` has fully replaced the `throw promise` pattern, we should
// be able to remove the equivalent check in finishConcurrentRender, and rely
// just on this one.
Expand All @@ -1705,29 +1715,22 @@ export function shouldRemainOnPreviousScreen(): boolean {
}
}

const handler = getSuspenseHandler();
if (handler === null) {
// TODO: We should support suspending in the case where there's no
// parent Suspense boundary, even outside a transition. Somehow. Otherwise,
// an uncached promise can fall into an infinite loop.
} else {
if (
includesOnlyRetries(workInProgressRootRenderLanes) ||
// In this context, an OffscreenLane counts as a Retry
// TODO: It's become increasingly clear that Retries and Offscreen are
// deeply connected. They probably can be unified further.
includesSomeLane(workInProgressRootRenderLanes, OffscreenLane)
) {
// During a retry, we can suspend rendering if the nearest Suspense boundary
// is the boundary of the "shell", because we're guaranteed not to block
// any new content from appearing.
//
// The reason we must check if this is a retry is because it guarantees
// that suspending the work loop won't block an actual update, because
// retries don't "update" anything; they fill in fallbacks that were left
// behind by a previous transition.
return handler === getShellBoundary();
}
if (
includesOnlyRetries(workInProgressRootRenderLanes) ||
// In this context, an OffscreenLane counts as a Retry
// TODO: It's become increasingly clear that Retries and Offscreen are
// deeply connected. They probably can be unified further.
includesSomeLane(workInProgressRootRenderLanes, OffscreenLane)
) {
// During a retry, we can suspend rendering if the nearest Suspense boundary
// is the boundary of the "shell", because we're guaranteed not to block
// any new content from appearing.
//
// The reason we must check if this is a retry is because it guarantees
// that suspending the work loop won't block an actual update, because
// retries don't "update" anything; they fill in fallbacks that were left
// behind by a previous transition.
return handler === getShellBoundary();
}

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

0 comments on commit dd480ef

Please sign in to comment.