Skip to content

[ReactDevTools] add custom error view for caught error #2

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 17 commits into from
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 0 additions & 11 deletions .github/ISSUE_TEMPLATE/react_18.md

This file was deleted.

104 changes: 52 additions & 52 deletions CHANGELOG.md

Large diffs are not rendered by default.

16 changes: 8 additions & 8 deletions ReactVersions.js
Original file line number Diff line number Diff line change
@@ -18,26 +18,26 @@
//
// 0.0.0-experimental-241c4467e-20200129

const ReactVersion = '18.0.0';
const ReactVersion = '18.1.0';

// The label used by the @next channel. Represents the upcoming release's
// stability. Could be "alpha", "beta", "rc", etc.
const nextChannelLabel = 'next';

const stablePackages = {
'create-subscription': ReactVersion,
'eslint-plugin-react-hooks': '4.4.0',
'jest-react': '0.12.1',
'eslint-plugin-react-hooks': '4.5.0',
'jest-react': '0.13.1',
react: ReactVersion,
'react-art': ReactVersion,
'react-dom': ReactVersion,
'react-is': ReactVersion,
'react-reconciler': '0.27.0',
'react-refresh': '0.12.0',
'react-reconciler': '0.28.0',
'react-refresh': '0.13.0',
'react-test-renderer': ReactVersion,
'use-subscription': '1.6.0',
'use-sync-external-store': '1.0.0',
scheduler: '0.21.0',
'use-subscription': '1.7.0',
'use-sync-external-store': '1.1.0',
scheduler: '0.22.0',
};

// These packages do not exist in the @next or @latest channel, only
30 changes: 30 additions & 0 deletions fixtures/ssr2/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# SSR Fixtures

A set of test cases for quickly identifying issues with server-side rendering.

## Setup

To reference a local build of React, first run `npm run build` at the root
of the React project. Then:

```
cd fixtures/ssr2
yarn
yarn start
```

The `start` command runs a webpack dev server and a server-side rendering server in development mode with hot reloading.

**Note: whenever you make changes to React and rebuild it, you need to re-run `yarn` in this folder:**

```
yarn
```

If you want to try the production mode instead run:

```
yarn start:prod
```

This will pre-build all static resources and then start a server-side rendering HTTP server that hosts the React app and service the static resources (without hot reloading).
1 change: 0 additions & 1 deletion fixtures/ssr2/scripts/build.js
Original file line number Diff line number Diff line change
@@ -40,7 +40,6 @@ webpack(
console.error(err.details);
}
process.exit(1);
return;
}
const info = stats.toJson();
if (stats.hasErrors()) {
8 changes: 6 additions & 2 deletions fixtures/ssr2/server/render.js
Original file line number Diff line number Diff line change
@@ -20,6 +20,7 @@ let assets = {
};

module.exports = function render(url, res) {
const data = createServerData();
// This is how you would wire it up previously:
//
// res.send(
@@ -36,14 +37,17 @@ module.exports = function render(url, res) {
console.error('Fatal', error);
});
let didError = false;
const data = createServerData();
const {pipe, abort} = renderToPipeableStream(
<DataProvider data={data}>
<App assets={assets} />
</DataProvider>,
{
bootstrapScripts: [assets['main.js']],
onCompleteShell() {
onAllReady() {
// Full completion.
// You can use this for SSG or crawlers.
},
onShellReady() {
// If something errored before we started streaming, we set the error code appropriately.
res.statusCode = didError ? 500 : 200;
res.setHeader('Content-type', 'text/html');
4 changes: 4 additions & 0 deletions packages/eslint-plugin-react-hooks/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 4.4.0

* No changes, this was an automated release together with React 18.

## 4.3.0

* Support ESLint 8. ([@MichaelDeBoey](https://github.com/MichaelDeBoey) in [#22248](https://github.com/facebook/react/pull/22248))
30 changes: 29 additions & 1 deletion packages/react-debug-tools/src/ReactDebugHooks.js
Original file line number Diff line number Diff line change
@@ -366,7 +366,7 @@ const DispatcherProxyHandler = {
const error = new Error('Missing method in Dispatcher: ' + prop);
// Note: This error name needs to stay in sync with react-devtools-shared
// TODO: refactor this if we ever combine the devtools and debug tools packages
error.name = 'UnsupportedFeatureError';
error.name = 'ReactDebugToolsUnsupportedHookError';
throw error;
},
};
@@ -667,6 +667,30 @@ function processDebugValues(
}
}

function handleRenderFunctionError(error: any): void {
// original error might be any type.
if (
error instanceof Error &&
error.name === 'ReactDebugToolsUnsupportedHookError'
) {
throw error;
}
// If the error is not caused by an unsupported feature, it means
// that the error is caused by user's code in renderFunction.
// In this case, we should wrap the original error inside a custom error
// so that devtools can give a clear message about it.
// $FlowFixMe: Flow doesn't know about 2nd argument of Error constructor
const wrapperError = new Error('Error rendering inspected component', {
cause: error,
});
// Note: This error name needs to stay in sync with react-devtools-shared
// TODO: refactor this if we ever combine the devtools and debug tools packages
wrapperError.name = 'ReactDebugToolsRenderError';
// this stage-4 proposal is not supported by all environments yet.
wrapperError.cause = error;
throw wrapperError;
}

export function inspectHooks<Props>(
renderFunction: Props => React$Node,
props: Props,
@@ -686,6 +710,8 @@ export function inspectHooks<Props>(
try {
ancestorStackError = new Error();
renderFunction(props);
} catch (error) {
handleRenderFunctionError(error);
} finally {
readHookLog = hookLog;
hookLog = [];
@@ -730,6 +756,8 @@ function inspectHooksOfForwardRef<Props, Ref>(
try {
ancestorStackError = new Error();
renderFunction(props, ref);
} catch (error) {
handleRenderFunctionError(error);
} finally {
readHookLog = hookLog;
hookLog = [];
Original file line number Diff line number Diff line change
@@ -276,10 +276,20 @@ describe('ReactHooksInspection', () => {
},
};

let didCatch = false;
expect(() => {
expect(() => {
// mock the Error constructor to check the internal of the error instance
try {
ReactDebugTools.inspectHooks(Foo, {}, FakeDispatcherRef);
}).toThrow("Cannot read property 'useState' of null");
} catch (error) {
expect(error.message).toBe('Error rendering inspected component');
// error.cause is the original error
expect(error.cause).toBeInstanceOf(Error);
expect(error.cause.message).toBe(
"Cannot read property 'useState' of null",
);
}
didCatch = true;
}).toErrorDev(
'Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for' +
' one of the following reasons:\n' +
@@ -289,6 +299,8 @@ describe('ReactHooksInspection', () => {
'See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem.',
{withoutStack: true},
);
// avoid false positive if no error was thrown at all
expect(didCatch).toBe(true);

expect(getterCalls).toBe(1);
expect(setterCalls).toHaveLength(2);
Original file line number Diff line number Diff line change
@@ -920,16 +920,26 @@ describe('ReactHooksInspectionIntegration', () => {

const renderer = ReactTestRenderer.create(<Foo />);
const childFiber = renderer.root._currentFiber();
expect(() => {

let didCatch = false;

try {
ReactDebugTools.inspectHooksOfFiber(childFiber, FakeDispatcherRef);
}).toThrow(
'Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for' +
' one of the following reasons:\n' +
'1. You might have mismatching versions of React and the renderer (such as React DOM)\n' +
'2. You might be breaking the Rules of Hooks\n' +
'3. You might have more than one copy of React in the same app\n' +
'See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem.',
);
} catch (error) {
expect(error.message).toBe('Error rendering inspected component');
expect(error.cause).toBeInstanceOf(Error);
expect(error.cause.message).toBe(
'Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for' +
' one of the following reasons:\n' +
'1. You might have mismatching versions of React and the renderer (such as React DOM)\n' +
'2. You might be breaking the Rules of Hooks\n' +
'3. You might have more than one copy of React in the same app\n' +
'See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem.',
);
didCatch = true;
}
// avoid false positive if no error was thrown at all
expect(didCatch).toBe(true);

expect(getterCalls).toBe(1);
expect(setterCalls).toHaveLength(2);
Original file line number Diff line number Diff line change
@@ -2145,7 +2145,7 @@ describe('InspectedElement', () => {
expect(value).toBe(null);

const error = errorBoundaryInstance.state.error;
expect(error.message).toBe('Expected');
expect(error.message).toBe('Error rendering inspected component');
expect(error.stack).toContain('inspectHooksOfFiber');
});

37 changes: 37 additions & 0 deletions packages/react-devtools-shared/src/backend/renderer.js
Original file line number Diff line number Diff line change
@@ -3606,6 +3606,43 @@ export function attach(
try {
mostRecentlyInspectedElement = inspectElementRaw(id);
} catch (error) {
// the error name is synced with ReactDebugHooks
if (error.name === 'ReactDebugToolsRenderError') {
let message = 'Error rendering inspected element.';
let stack;
// Log error & cause for user to debug
console.error(message + '\n\n', error);
if (error.cause != null) {
console.error(
'Original error causing above error: \n\n',
error.cause,
);
if (error.cause instanceof Error) {
message = error.cause.message || message;
stack = error.cause.stack;
}
}

return {
type: 'user-error',
id,
responseID: requestID,
message,
stack,
};
}

// the error name is synced with ReactDebugHooks
if (error.name === 'ReactDebugToolsUnsupportedHookError') {
return {
type: 'unsupported-feature',
id,
responseID: requestID,
message: 'Unsupported feature: ' + error.message,
};
}

// Log Uncaught Error
console.error('Error inspecting element.\n\n', error);

return {
19 changes: 19 additions & 0 deletions packages/react-devtools-shared/src/backend/types.js
Original file line number Diff line number Diff line change
@@ -281,6 +281,8 @@ export type InspectedElement = {|
|};

export const InspectElementErrorType = 'error';
export const InspectElementUserErrorType = 'user-error';
export const InspectElementUnsupportedFeatureErrorType = 'unsupported-feature';
export const InspectElementFullDataType = 'full-data';
export const InspectElementNoChangeType = 'no-change';
export const InspectElementNotFoundType = 'not-found';
@@ -293,6 +295,21 @@ export type InspectElementError = {|
stack: string,
|};

export type InspectElementUserError = {|
id: number,
responseID: number,
type: 'user-error',
message: string,
stack: ?string,
|};

export type InspectElementUnsupportedFeatureError = {|
id: number,
responseID: number,
type: 'unsupported-feature',
message: string,
|};

export type InspectElementFullData = {|
id: number,
responseID: number,
@@ -322,6 +339,8 @@ export type InspectElementNotFound = {|

export type InspectedElementPayload =
| InspectElementError
| InspectElementUserError
| InspectElementUnsupportedFeatureError
| InspectElementFullData
| InspectElementHydratedPath
| InspectElementNoChange
2 changes: 1 addition & 1 deletion packages/react-devtools-shared/src/backendAPI.js
Original file line number Diff line number Diff line change
@@ -10,7 +10,7 @@
import {hydrate, fillInPath} from 'react-devtools-shared/src/hydration';
import {separateDisplayNameAndHOCs} from 'react-devtools-shared/src/utils';
import Store from 'react-devtools-shared/src/devtools/store';
import TimeoutError from 'react-devtools-shared/src/TimeoutError';
import TimeoutError from 'react-devtools-shared/src/errors/TimeoutError';

import type {
InspectedElement as InspectedElementBackend,
Original file line number Diff line number Diff line change
@@ -63,6 +63,7 @@ export type OwnersList = {|

export type InspectedElementResponseType =
| 'error'
| 'user-error'
| 'full-data'
| 'hydrated-path'
| 'no-change'
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/

import * as React from 'react';
import styles from './shared.css';

type Props = {|
callStack: string | null,
children: React$Node,
info: React$Node | null,
componentStack: string | null,
errorMessage: string,
|};

export default function CaughtErrorView({
callStack,
children,
info,
componentStack,
errorMessage,
}: Props) {
return (
<div className={styles.ErrorBoundary}>
{children}
<div className={styles.ErrorInfo}>
<div className={styles.HeaderRow}>
<div className={styles.ErrorHeader}>{errorMessage}</div>
</div>
{!!info && <div className={styles.InfoBox}>{info}</div>}
{!!callStack && (
<div className={styles.ErrorStack}>
The error was thrown {callStack.trim()}
</div>
)}
</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -15,8 +15,11 @@ import ErrorView from './ErrorView';
import SearchingGitHubIssues from './SearchingGitHubIssues';
import SuspendingErrorView from './SuspendingErrorView';
import TimeoutView from './TimeoutView';
import CaughtErrorView from './CaughtErrorView';
import UnsupportedBridgeOperationError from 'react-devtools-shared/src/UnsupportedBridgeOperationError';
import TimeoutError from 'react-devtools-shared/src/TimeoutError';
import TimeoutError from 'react-devtools-shared/src/errors/TimeoutError';
import UserError from 'react-devtools-shared/src/errors/UserError';
import UnsupportedFeatureError from 'react-devtools-shared/src/errors/UnsupportedFeatureError';
import {logEvent} from 'react-devtools-shared/src/Logger';

type Props = {|
@@ -34,6 +37,8 @@ type State = {|
hasError: boolean,
isUnsupportedBridgeOperationError: boolean,
isTimeout: boolean,
isUserError: boolean,
isUnsupportedFeatureError: boolean,
|};

const InitialState: State = {
@@ -44,6 +49,8 @@ const InitialState: State = {
hasError: false,
isUnsupportedBridgeOperationError: false,
isTimeout: false,
isUserError: false,
isUnsupportedFeatureError: false,
};

export default class ErrorBoundary extends Component<Props, State> {
@@ -58,6 +65,8 @@ export default class ErrorBoundary extends Component<Props, State> {
: null;

const isTimeout = error instanceof TimeoutError;
const isUserError = error instanceof UserError;
const isUnsupportedFeatureError = error instanceof UnsupportedFeatureError;
const isUnsupportedBridgeOperationError =
error instanceof UnsupportedBridgeOperationError;

@@ -76,7 +85,9 @@ export default class ErrorBoundary extends Component<Props, State> {
errorMessage,
hasError: true,
isUnsupportedBridgeOperationError,
isUnsupportedFeatureError,
isTimeout,
isUserError,
};
}

@@ -111,6 +122,8 @@ export default class ErrorBoundary extends Component<Props, State> {
hasError,
isUnsupportedBridgeOperationError,
isTimeout,
isUserError,
isUnsupportedFeatureError,
} = this.state;

if (hasError) {
@@ -133,6 +146,38 @@ export default class ErrorBoundary extends Component<Props, State> {
errorMessage={errorMessage}
/>
);
} else if (isUserError) {
return (
<CaughtErrorView
callStack={callStack}
componentStack={componentStack}
errorMessage={errorMessage || 'Error occured in inspected element'}
info={
<>
This is likely to be caused by implementation of current
inspected element. Please see your console for logged error.
</>
}
/>
);
} else if (isUnsupportedFeatureError) {
return (
<CaughtErrorView
callStack={callStack}
componentStack={componentStack}
errorMessage={
errorMessage ||
'Current DevTools version does not support a feature used in the inspected element.'
}
info={
<>
React DevTools is unable to handle a feature you are using in
this component (e.g. a new React build-in Hook). Please upgrade
to the latest version.
</>
}
/>
);
} else {
return (
<ErrorView
@@ -141,10 +186,7 @@ export default class ErrorBoundary extends Component<Props, State> {
dismissError={
canDismissProp || canDismissState ? this._dismissError : null
}
errorMessage={errorMessage}
isUnsupportedBridgeOperationError={
isUnsupportedBridgeOperationError
}>
errorMessage={errorMessage}>
<Suspense fallback={<SearchingGitHubIssues />}>
<SuspendingErrorView
callStack={callStack}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/

export default class UnsupportedFeatureError extends Error {
constructor(message: string) {
super(message);

// Maintains proper stack trace for where our error was thrown (only available on V8)
if (Error.captureStackTrace) {
Error.captureStackTrace(this, UnsupportedFeatureError);
}

this.name = 'UnsupportedFeatureError';
}
}
21 changes: 21 additions & 0 deletions packages/react-devtools-shared/src/errors/UserError.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/

export default class UserError extends Error {
constructor(message: string) {
super(message);

// Maintains proper stack trace for where our error was thrown (only available on V8)
if (Error.captureStackTrace) {
Error.captureStackTrace(this, UserError);
}

this.name = 'UserError';
}
}
Original file line number Diff line number Diff line change
@@ -8,6 +8,7 @@
*/

import LRU from 'lru-cache';
import {UserHookError} from 'react-debug-tools';
import {
convertInspectedElementBackendToFrontend,
hydrateHelper,
@@ -19,6 +20,8 @@ import type {LRUCache} from 'react-devtools-shared/src/types';
import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
import type {
InspectElementError,
InspectElementUserError,
InspectElementUnsupportedFeatureError,
InspectElementFullData,
InspectElementHydratedPath,
} from 'react-devtools-shared/src/backend/types';
@@ -27,6 +30,8 @@ import type {
InspectedElement as InspectedElementFrontend,
InspectedElementResponseType,
} from 'react-devtools-shared/src/devtools/views/Components/types';
import UserError from 'react-devtools-shared/src/errors/UserError';
import UnsupportedFeatureError from 'react-devtools-shared/src/errors/UnsupportedFeatureError';

// Maps element ID to inspected data.
// We use an LRU for this rather than a WeakMap because of how the "no-change" optimization works.
@@ -80,14 +85,30 @@ export function inspectElement({

let inspectedElement;
switch (type) {
case 'error':
case 'error': {
const {message, stack} = ((data: any): InspectElementError);

// The backend's stack (where the error originated) is more meaningful than this stack.
const error = new Error(message);
error.stack = stack;

throw error;
}

case 'user-error': {
const {message, stack} = (data: InspectElementUserError);
// Trying to keep useful information from user's side.
const error = new UserError(message);
error.stack = stack || error.stack;
throw error;
}

case 'unsupported-feature': {
const {message} = (data: InspectElementUnsupportedFeatureError);
// Trying to keep useful information from user's side.
const error = new UnsupportedFeatureError(message);
throw error;
}

case 'no-change':
// This is a no-op for the purposes of our cache.
308 changes: 294 additions & 14 deletions packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
Original file line number Diff line number Diff line change
@@ -869,16 +869,16 @@ describe('ReactDOMFizzServer', () => {
});

// We still can't render it on the client.
expect(Scheduler).toFlushAndYield([
'The server could not finish this Suspense boundary, likely due to an ' +
'error during server rendering. Switched to client rendering.',
]);
expect(Scheduler).toFlushAndYield([]);
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);

// We now resolve it on the client.
resolveText('Hello');

Scheduler.unstable_flushAll();
expect(Scheduler).toFlushAndYield([
'The server could not finish this Suspense boundary, likely due to an ' +
'error during server rendering. Switched to client rendering.',
]);

// The client rendered HTML is now in place.
expect(getVisibleChildren(container)).toEqual(
@@ -2220,6 +2220,286 @@ describe('ReactDOMFizzServer', () => {
},
);

// @gate experimental
it('does not recreate the fallback if server errors and hydration suspends', async () => {
let isClient = false;

function Child() {
if (isClient) {
readText('Yay!');
} else {
throw Error('Oops.');
}
Scheduler.unstable_yieldValue('Yay!');
return 'Yay!';
}

const fallbackRef = React.createRef();
function App() {
return (
<div>
<Suspense fallback={<p ref={fallbackRef}>Loading...</p>}>
<span>
<Child />
</span>
</Suspense>
</div>
);
}
await act(async () => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />, {
onError(error) {
Scheduler.unstable_yieldValue('[s!] ' + error.message);
},
});
pipe(writable);
});
expect(Scheduler).toHaveYielded(['[s!] Oops.']);

// The server could not complete this boundary, so we'll retry on the client.
const serverFallback = container.getElementsByTagName('p')[0];
expect(serverFallback.innerHTML).toBe('Loading...');

// Hydrate the tree. This will suspend.
isClient = true;
ReactDOMClient.hydrateRoot(container, <App />, {
onRecoverableError(error) {
Scheduler.unstable_yieldValue('[c!] ' + error.message);
},
});
// This should not report any errors yet.
expect(Scheduler).toFlushAndYield([]);
expect(getVisibleChildren(container)).toEqual(
<div>
<p>Loading...</p>
</div>,
);

// Normally, hydrating after server error would force a clean client render.
// However, it suspended so at best we'd only get the same fallback anyway.
// We don't want to recreate the same fallback in the DOM again because
// that's extra work and would restart animations etc. Check we don't do that.
const clientFallback = container.getElementsByTagName('p')[0];
expect(serverFallback).toBe(clientFallback);

// When we're able to fully hydrate, we expect a clean client render.
await act(async () => {
resolveText('Yay!');
});
expect(Scheduler).toFlushAndYield([
'Yay!',
'[c!] The server could not finish this Suspense boundary, ' +
'likely due to an error during server rendering. ' +
'Switched to client rendering.',
]);
expect(getVisibleChildren(container)).toEqual(
<div>
<span>Yay!</span>
</div>,
);
});

// @gate experimental
it(
'does not recreate the fallback if server errors and hydration suspends ' +
'and root receives a transition',
async () => {
let isClient = false;

function Child({color}) {
if (isClient) {
readText('Yay!');
} else {
throw Error('Oops.');
}
Scheduler.unstable_yieldValue('Yay! (' + color + ')');
return 'Yay! (' + color + ')';
}

const fallbackRef = React.createRef();
function App({color}) {
return (
<div>
<Suspense fallback={<p ref={fallbackRef}>Loading...</p>}>
<span>
<Child color={color} />
</span>
</Suspense>
</div>
);
}
await act(async () => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<App color="red" />,
{
onError(error) {
Scheduler.unstable_yieldValue('[s!] ' + error.message);
},
},
);
pipe(writable);
});
expect(Scheduler).toHaveYielded(['[s!] Oops.']);

// The server could not complete this boundary, so we'll retry on the client.
const serverFallback = container.getElementsByTagName('p')[0];
expect(serverFallback.innerHTML).toBe('Loading...');

// Hydrate the tree. This will suspend.
isClient = true;
const root = ReactDOMClient.hydrateRoot(container, <App color="red" />, {
onRecoverableError(error) {
Scheduler.unstable_yieldValue('[c!] ' + error.message);
},
});
// This should not report any errors yet.
expect(Scheduler).toFlushAndYield([]);
expect(getVisibleChildren(container)).toEqual(
<div>
<p>Loading...</p>
</div>,
);

// Normally, hydrating after server error would force a clean client render.
// However, it suspended so at best we'd only get the same fallback anyway.
// We don't want to recreate the same fallback in the DOM again because
// that's extra work and would restart animations etc. Check we don't do that.
const clientFallback = container.getElementsByTagName('p')[0];
expect(serverFallback).toBe(clientFallback);

// Transition updates shouldn't recreate the fallback either.
React.startTransition(() => {
root.render(<App color="blue" />);
});
Scheduler.unstable_flushAll();
jest.runAllTimers();
const clientFallback2 = container.getElementsByTagName('p')[0];
expect(clientFallback2).toBe(serverFallback);

// When we're able to fully hydrate, we expect a clean client render.
await act(async () => {
resolveText('Yay!');
});
expect(Scheduler).toFlushAndYield([
'Yay! (red)',
'[c!] The server could not finish this Suspense boundary, ' +
'likely due to an error during server rendering. ' +
'Switched to client rendering.',
'Yay! (blue)',
]);
expect(getVisibleChildren(container)).toEqual(
<div>
<span>Yay! (blue)</span>
</div>,
);
},
);

// @gate experimental
it(
'recreates the fallback if server errors and hydration suspends but ' +
'client receives new props',
async () => {
let isClient = false;

function Child() {
const value = 'Yay!';
if (isClient) {
readText(value);
} else {
throw Error('Oops.');
}
Scheduler.unstable_yieldValue(value);
return value;
}

const fallbackRef = React.createRef();
function App({fallbackText}) {
return (
<div>
<Suspense fallback={<p ref={fallbackRef}>{fallbackText}</p>}>
<span>
<Child />
</span>
</Suspense>
</div>
);
}

await act(async () => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<App fallbackText="Loading..." />,
{
onError(error) {
Scheduler.unstable_yieldValue('[s!] ' + error.message);
},
},
);
pipe(writable);
});
expect(Scheduler).toHaveYielded(['[s!] Oops.']);

const serverFallback = container.getElementsByTagName('p')[0];
expect(serverFallback.innerHTML).toBe('Loading...');

// Hydrate the tree. This will suspend.
isClient = true;
const root = ReactDOMClient.hydrateRoot(
container,
<App fallbackText="Loading..." />,
{
onRecoverableError(error) {
Scheduler.unstable_yieldValue('[c!] ' + error.message);
},
},
);
// This should not report any errors yet.
expect(Scheduler).toFlushAndYield([]);
expect(getVisibleChildren(container)).toEqual(
<div>
<p>Loading...</p>
</div>,
);

// Normally, hydration after server error would force a clean client render.
// However, that suspended so at best we'd only get a fallback anyway.
// We don't want to replace a fallback with the same fallback because
// that's extra work and would restart animations etc. Verify we don't do that.
const clientFallback1 = container.getElementsByTagName('p')[0];
expect(serverFallback).toBe(clientFallback1);

// However, an update may have changed the fallback props. In that case we have to
// actually force it to re-render on the client and throw away the server one.
root.render(<App fallbackText="More loading..." />);
Scheduler.unstable_flushAll();
jest.runAllTimers();
expect(Scheduler).toHaveYielded([
'[c!] The server could not finish this Suspense boundary, ' +
'likely due to an error during server rendering. ' +
'Switched to client rendering.',
]);
expect(getVisibleChildren(container)).toEqual(
<div>
<p>More loading...</p>
</div>,
);
// This should be a clean render without reusing DOM.
const clientFallback2 = container.getElementsByTagName('p')[0];
expect(clientFallback2).not.toBe(clientFallback1);

// Verify we can still do a clean content render after.
await act(async () => {
resolveText('Yay!');
});
expect(Scheduler).toFlushAndYield(['Yay!']);
expect(getVisibleChildren(container)).toEqual(
<div>
<span>Yay!</span>
</div>,
);
},
);

// @gate experimental
it(
'errors during hydration force a client render at the nearest Suspense ' +
@@ -2293,25 +2573,25 @@ describe('ReactDOMFizzServer', () => {
},
});

// An error logged but instead of surfacing it to the UI, we switched
// to client rendering.
expect(Scheduler).toFlushAndYield([
'Hydration error',
'There was an error while hydrating this Suspense boundary. Switched ' +
'to client rendering.',
]);
// An error happened but instead of surfacing it to the UI, we suspended.
expect(Scheduler).toFlushAndYield([]);
expect(getVisibleChildren(container)).toEqual(
<div>
<span />
Loading...
<span>Yay!</span>
<span />
</div>,
);

await act(async () => {
resolveText('Yay!');
});
expect(Scheduler).toFlushAndYield(['Yay!']);
expect(Scheduler).toFlushAndYield([
'Yay!',
'Hydration error',
'There was an error while hydrating this Suspense boundary. Switched ' +
'to client rendering.',
]);
expect(getVisibleChildren(container)).toEqual(
<div>
<span />
1,311 changes: 1,311 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMHydrationDiff-test.js

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -2137,10 +2137,7 @@ describe('ReactDOMServerPartialHydration', () => {
});

suspend = true;
expect(Scheduler).toFlushAndYield([
'The server could not finish this Suspense boundary, likely due to ' +
'an error during server rendering. Switched to client rendering.',
]);
expect(Scheduler).toFlushAndYield([]);

// We haven't hydrated the second child but the placeholder is still in the list.
expect(container.textContent).toBe('ALoading B');
1 change: 1 addition & 0 deletions packages/react-reconciler/src/ReactFiberBeginWork.new.js
Original file line number Diff line number Diff line change
@@ -2241,6 +2241,7 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) {
} else {
// Suspended but we should no longer be in dehydrated mode.
// Therefore we now have to render the fallback.
renderDidSuspendDelayIfPossible();
const nextPrimaryChildren = nextProps.children;
const nextFallbackChildren = nextProps.fallback;
const fallbackChildFragment = mountSuspenseFallbackAfterRetryWithoutHydrating(
1 change: 1 addition & 0 deletions packages/react-reconciler/src/ReactFiberBeginWork.old.js
Original file line number Diff line number Diff line change
@@ -2241,6 +2241,7 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) {
} else {
// Suspended but we should no longer be in dehydrated mode.
// Therefore we now have to render the fallback.
renderDidSuspendDelayIfPossible();
const nextPrimaryChildren = nextProps.children;
const nextFallbackChildren = nextProps.fallback;
const fallbackChildFragment = mountSuspenseFallbackAfterRetryWithoutHydrating(
5 changes: 3 additions & 2 deletions packages/react-reconciler/src/ReactFiberLane.new.js
Original file line number Diff line number Diff line change
@@ -458,8 +458,9 @@ export function includesNonIdleWork(lanes: Lanes) {
export function includesOnlyRetries(lanes: Lanes) {
return (lanes & RetryLanes) === lanes;
}
export function includesOnlyTransitions(lanes: Lanes) {
return (lanes & TransitionLanes) === lanes;
export function includesOnlyNonUrgentLanes(lanes: Lanes) {
const UrgentLanes = SyncLane | InputContinuousLane | DefaultLane;
return (lanes & UrgentLanes) === NoLanes;
}

export function includesBlockingLane(root: FiberRoot, lanes: Lanes) {
5 changes: 3 additions & 2 deletions packages/react-reconciler/src/ReactFiberLane.old.js
Original file line number Diff line number Diff line change
@@ -458,8 +458,9 @@ export function includesNonIdleWork(lanes: Lanes) {
export function includesOnlyRetries(lanes: Lanes) {
return (lanes & RetryLanes) === lanes;
}
export function includesOnlyTransitions(lanes: Lanes) {
return (lanes & TransitionLanes) === lanes;
export function includesOnlyNonUrgentLanes(lanes: Lanes) {
const UrgentLanes = SyncLane | InputContinuousLane | DefaultLane;
return (lanes & UrgentLanes) === NoLanes;
}

export function includesBlockingLane(root: FiberRoot, lanes: Lanes) {
4 changes: 2 additions & 2 deletions packages/react-reconciler/src/ReactFiberWorkLoop.new.js
Original file line number Diff line number Diff line change
@@ -132,7 +132,7 @@ import {
pickArbitraryLane,
includesNonIdleWork,
includesOnlyRetries,
includesOnlyTransitions,
includesOnlyNonUrgentLanes,
includesBlockingLane,
includesExpiredLane,
getNextLanes,
@@ -1110,7 +1110,7 @@ function finishConcurrentRender(root, exitStatus, lanes) {
case RootSuspendedWithDelay: {
markRootSuspended(root, lanes);

if (includesOnlyTransitions(lanes)) {
if (includesOnlyNonUrgentLanes(lanes)) {
// This is a transition, so we should exit without committing a
// placeholder and without scheduling a timeout. Delay indefinitely
// until we receive more data.
4 changes: 2 additions & 2 deletions packages/react-reconciler/src/ReactFiberWorkLoop.old.js
Original file line number Diff line number Diff line change
@@ -132,7 +132,7 @@ import {
pickArbitraryLane,
includesNonIdleWork,
includesOnlyRetries,
includesOnlyTransitions,
includesOnlyNonUrgentLanes,
includesBlockingLane,
includesExpiredLane,
getNextLanes,
@@ -1110,7 +1110,7 @@ function finishConcurrentRender(root, exitStatus, lanes) {
case RootSuspendedWithDelay: {
markRootSuspended(root, lanes);

if (includesOnlyTransitions(lanes)) {
if (includesOnlyNonUrgentLanes(lanes)) {
// This is a transition, so we should exit without committing a
// placeholder and without scheduling a timeout. Delay indefinitely
// until we receive more data.
1 change: 0 additions & 1 deletion packages/react/index.experimental.js
Original file line number Diff line number Diff line change
@@ -22,7 +22,6 @@ export {
createContext,
createElement,
createFactory,
createMutableSource as unstable_createMutableSource,
createRef,
createServerContext,
forwardRef,