Skip to content
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

Warn for invalid type in renderer with the correct RSC stack #30102

Merged
merged 8 commits into from
Jun 27, 2024
Merged
Show file tree
Hide file tree
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
22 changes: 15 additions & 7 deletions packages/react-client/src/__tests__/ReactFlight-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -692,14 +692,22 @@ describe('ReactFlight', () => {

const transport = ReactNoopFlightServer.render(<ServerComponent />);

await act(async () => {
const rootModel = await ReactNoopFlightClient.read(transport);
ReactNoop.render(rootModel);
});
expect(ReactNoop).toMatchRenderedOutput('Loading...');
spyOnDevAndProd(console, 'error').mockImplementation(() => {});
await load();
expect(console.error).toHaveBeenCalledTimes(1);

await expect(async () => {
await act(async () => {
const rootModel = await ReactNoopFlightClient.read(transport);
ReactNoop.render(rootModel);
});
}).rejects.toThrow(
__DEV__
? 'Element type is invalid: expected a string (for built-in components) or a class/function ' +
'(for composite components) but got: <div />. ' +
'Did you accidentally export a JSX literal instead of a component?'
: 'Element type is invalid: expected a string (for built-in components) or a class/function ' +
'(for composite components) but got: object.',
);
expect(ReactNoop).toMatchRenderedOutput(null);
});

it('can render a lazy element', async () => {
Expand Down
84 changes: 58 additions & 26 deletions packages/react-dom/src/__tests__/ReactComponent-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ let ReactDOM;
let ReactDOMClient;
let ReactDOMServer;
let act;
let assertConsoleErrorDev;

describe('ReactComponent', () => {
beforeEach(() => {
Expand All @@ -24,6 +25,8 @@ describe('ReactComponent', () => {
ReactDOMClient = require('react-dom/client');
ReactDOMServer = require('react-dom/server');
act = require('internal-test-utils').act;
assertConsoleErrorDev =
require('internal-test-utils').assertConsoleErrorDev;
});

// @gate !disableLegacyMode
Expand Down Expand Up @@ -131,8 +134,6 @@ describe('ReactComponent', () => {

// @gate !disableStringRefs
it('string refs do not detach and reattach on every render', async () => {
spyOnDev(console, 'error').mockImplementation(() => {});

let refVal;
class Child extends React.Component {
componentDidUpdate() {
Expand Down Expand Up @@ -171,6 +172,8 @@ describe('ReactComponent', () => {
root.render(<Parent />);
});

assertConsoleErrorDev(['contains the string ref']);

expect(refVal).toBe(undefined);
await act(() => {
root.render(<Parent showChild={true} />);
Expand Down Expand Up @@ -511,19 +514,25 @@ describe('ReactComponent', () => {
});

it('throws usefully when rendering badly-typed elements', async () => {
const container = document.createElement('div');
const root = ReactDOMClient.createRoot(container);

const X = undefined;
let container = document.createElement('div');
let root = ReactDOMClient.createRoot(container);
await expect(
expect(async () => {
await act(() => {
root.render(<X />);
});
}).toErrorDev(
'React.jsx: type is invalid -- expected a string (for built-in components) ' +
'or a class/function (for composite components) but got: undefined.',
),
).rejects.toThrowError(
const XElement = <X />;
if (gate(flags => !flags.enableOwnerStacks)) {
assertConsoleErrorDev(
[
'React.jsx: type is invalid -- expected a string (for built-in components) ' +
'or a class/function (for composite components) but got: undefined.',
],
{withoutStack: true},
);
}
await expect(async () => {
await act(() => {
root.render(XElement);
});
}).rejects.toThrowError(
'Element type is invalid: expected a string (for built-in components) ' +
'or a class/function (for composite components) but got: undefined.' +
(__DEV__
Expand All @@ -533,21 +542,44 @@ describe('ReactComponent', () => {
);

const Y = null;
container = document.createElement('div');
root = ReactDOMClient.createRoot(container);
await expect(
expect(async () => {
await act(() => {
root.render(<Y />);
});
}).toErrorDev(
'React.jsx: type is invalid -- expected a string (for built-in components) ' +
'or a class/function (for composite components) but got: null.',
),
).rejects.toThrowError(
const YElement = <Y />;
if (gate(flags => !flags.enableOwnerStacks)) {
assertConsoleErrorDev(
[
'React.jsx: type is invalid -- expected a string (for built-in components) ' +
'or a class/function (for composite components) but got: null.',
],
{withoutStack: true},
);
}
await expect(async () => {
await act(() => {
root.render(YElement);
});
}).rejects.toThrowError(
'Element type is invalid: expected a string (for built-in components) ' +
'or a class/function (for composite components) but got: null.',
);

const Z = true;
const ZElement = <Z />;
if (gate(flags => !flags.enableOwnerStacks)) {
assertConsoleErrorDev(
[
'React.jsx: type is invalid -- expected a string (for built-in components) ' +
'or a class/function (for composite components) but got: boolean.',
],
{withoutStack: true},
);
}
await expect(async () => {
await act(() => {
root.render(ZElement);
});
}).rejects.toThrowError(
'Element type is invalid: expected a string (for built-in components) ' +
'or a class/function (for composite components) but got: boolean.',
);
});

it('includes owner name in the error about badly-typed elements', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -987,11 +987,13 @@ describe('ReactDOMServerIntegration', () => {
expect(() => {
EmptyComponent = <EmptyComponent />;
}).toErrorDev(
'React.jsx: type is invalid -- expected a string ' +
'(for built-in components) or a class/function (for composite ' +
'components) but got: object. You likely forgot to export your ' +
"component from the file it's defined in, or you might have mixed up " +
'default and named imports.',
gate(flags => flags.enableOwnerStacks)
? []
: 'React.jsx: type is invalid -- expected a string ' +
'(for built-in components) or a class/function (for composite ' +
'components) but got: object. You likely forgot to export your ' +
"component from the file it's defined in, or you might have mixed up " +
'default and named imports.',
{withoutStack: true},
);
await render(EmptyComponent);
Expand All @@ -1011,9 +1013,11 @@ describe('ReactDOMServerIntegration', () => {
expect(() => {
NullComponent = <NullComponent />;
}).toErrorDev(
'React.jsx: type is invalid -- expected a string ' +
'(for built-in components) or a class/function (for composite ' +
'components) but got: null.',
gate(flags => flags.enableOwnerStacks)
? []
: 'React.jsx: type is invalid -- expected a string ' +
'(for built-in components) or a class/function (for composite ' +
'components) but got: null.',
{withoutStack: true},
);
await render(NullComponent);
Expand All @@ -1029,11 +1033,13 @@ describe('ReactDOMServerIntegration', () => {
expect(() => {
UndefinedComponent = <UndefinedComponent />;
}).toErrorDev(
'React.jsx: type is invalid -- expected a string ' +
'(for built-in components) or a class/function (for composite ' +
'components) but got: undefined. You likely forgot to export your ' +
"component from the file it's defined in, or you might have mixed up " +
'default and named imports.',
gate(flags => flags.enableOwnerStacks)
? []
: 'React.jsx: type is invalid -- expected a string ' +
'(for built-in components) or a class/function (for composite ' +
'components) but got: undefined. You likely forgot to export your ' +
"component from the file it's defined in, or you might have mixed up " +
'default and named imports.',
{withoutStack: true},
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ let PropTypes;
let React;
let ReactDOM;
let act;
let assertConsoleErrorDev;

// TODO: Refactor this test once componentDidCatch setState is deprecated.
describe('ReactLegacyErrorBoundaries', () => {
Expand Down Expand Up @@ -42,6 +43,8 @@ describe('ReactLegacyErrorBoundaries', () => {
ReactDOM = require('react-dom');
React = require('react');
act = require('internal-test-utils').act;
assertConsoleErrorDev =
require('internal-test-utils').assertConsoleErrorDev;

log = [];

Expand Down Expand Up @@ -2099,32 +2102,38 @@ describe('ReactLegacyErrorBoundaries', () => {
const Y = undefined;

await expect(async () => {
await expect(async () => {
const container = document.createElement('div');
await act(() => {
ReactDOM.render(<X />, container);
});
}).rejects.toThrow('got: null');
}).toErrorDev(
'React.jsx: type is invalid -- expected a string ' +
'(for built-in components) or a class/function ' +
'(for composite components) but got: null.',
{withoutStack: 1},
);
const container = document.createElement('div');
await act(() => {
ReactDOM.render(<X />, container);
});
}).rejects.toThrow('got: null');
if (gate(flags => !flags.enableOwnerStacks)) {
assertConsoleErrorDev(
[
'React.jsx: type is invalid -- expected a string ' +
'(for built-in components) or a class/function ' +
'(for composite components) but got: null.',
],
{withoutStack: true},
);
}

await expect(async () => {
await expect(async () => {
const container = document.createElement('div');
await act(() => {
ReactDOM.render(<Y />, container);
});
}).rejects.toThrow('got: undefined');
}).toErrorDev(
'React.jsx: type is invalid -- expected a string ' +
'(for built-in components) or a class/function ' +
'(for composite components) but got: undefined.',
{withoutStack: 1},
);
const container = document.createElement('div');
await act(() => {
ReactDOM.render(<Y />, container);
});
}).rejects.toThrow('got: undefined');
if (gate(flags => !flags.enableOwnerStacks)) {
assertConsoleErrorDev(
[
'React.jsx: type is invalid -- expected a string ' +
'(for built-in components) or a class/function ' +
'(for composite components) but got: undefined.',
],
{withoutStack: true},
);
}
});

// @gate !disableLegacyMode
Expand Down
6 changes: 6 additions & 0 deletions packages/react-reconciler/src/ReactChildFiber.js
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,9 @@ function validateFragmentProps(
// For unkeyed root fragments there's no Fiber. We create a fake one just for
// error stack handling.
fiber = createFiberFromElement(element, returnFiber.mode, 0);
if (__DEV__) {
fiber._debugInfo = currentDebugInfo;
}
fiber.return = returnFiber;
}
runWithFiberInDEV(
Expand All @@ -242,6 +245,9 @@ function validateFragmentProps(
// For unkeyed root fragments there's no Fiber. We create a fake one just for
// error stack handling.
fiber = createFiberFromElement(element, returnFiber.mode, 0);
if (__DEV__) {
fiber._debugInfo = currentDebugInfo;
}
fiber.return = returnFiber;
}
runWithFiberInDEV(fiber, () => {
Expand Down
10 changes: 9 additions & 1 deletion packages/react-reconciler/src/ReactFiber.js
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,7 @@ export function createHostRootFiber(
return createFiber(HostRoot, null, null, mode);
}

// TODO: Get rid of this helper. Only createFiberFromElement should exist.
export function createFiberFromTypeAndProps(
type: any, // React$ElementType
key: null | string,
Expand Down Expand Up @@ -650,11 +651,18 @@ export function createFiberFromTypeAndProps(
typeString = type === null ? 'null' : typeof type;
}

throw new Error(
// The type is invalid but it's conceptually a child that errored and not the
// current component itself so we create a virtual child that throws in its
// begin phase. This is the same thing we do in ReactChildFiber if we throw
// but we do it here so that we can assign the debug owner and stack from the
// element itself. That way the error stack will point to the JSX callsite.
fiberTag = Throw;
pendingProps = new Error(
'Element type is invalid: expected a string (for built-in ' +
'components) or a class/function (for composite components) ' +
`but got: ${typeString}.${info}`,
);
resolvedType = null;
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@ describe('ErrorBoundaryReconciliation', () => {
let ReactTestRenderer;
let span;
let act;
let assertConsoleErrorDev;

beforeEach(() => {
jest.resetModules();

ReactTestRenderer = require('react-test-renderer');
React = require('react');
act = require('internal-test-utils').act;
assertConsoleErrorDev =
require('internal-test-utils').assertConsoleErrorDev;
DidCatchErrorBoundary = class extends React.Component {
state = {error: null};
componentDidCatch(error) {
Expand Down Expand Up @@ -58,15 +61,17 @@ describe('ErrorBoundaryReconciliation', () => {
);
});
expect(renderer).toMatchRenderedOutput(<span prop="BrokenRender" />);
await expect(async () => {
await act(() => {
renderer.update(
<ErrorBoundary fallbackTagName={fallbackTagName}>
<BrokenRender fail={true} />
</ErrorBoundary>,
);
});
}).toErrorDev(['invalid', 'invalid']);
await act(() => {
renderer.update(
<ErrorBoundary fallbackTagName={fallbackTagName}>
<BrokenRender fail={true} />
</ErrorBoundary>,
);
});
if (gate(flags => !flags.enableOwnerStacks)) {
assertConsoleErrorDev(['invalid', 'invalid']);
}

const Fallback = fallbackTagName;
expect(renderer).toMatchRenderedOutput(<Fallback prop="ErrorBoundary" />);
}
Expand Down
Loading