Skip to content

Commit e02baf6

Browse files
authored
Warn for invalid type in renderer with the correct RSC stack (#30102)
This is all behind the `enableOwnerStacks` flag. This is a follow up to #29088. In that I moved type validation into the renderer since that's the one that knows what types are allowed. However, I only removed it from `React.createElement` and not the JSX which was an oversight. However, I also noticed that for invalid types we don't have the right stack trace for throws because we're not yet inside the JSX element that itself is invalid. We should use its stack for the stack trace. That's the reason it's enough to just use the throw now because we can get a good stack trace from the owner stack. This is fixed by creating a fake Throw Fiber that gets assigned the right stack. Additionally, I noticed that for certain invalid types like the most common one `undefined` we error in Flight so a missing import in RSC leads to a generic error. Instead of erroring on the Flight side we should just let anything that's not a Server Component through to the client and then let the Client renderer determine whether it's a valid type or not. Since we now have owner stacks through the server too, this will still be able to provide a good stack trace on the client that points to the server in that case. <img width="571" alt="Screenshot 2024-06-25 at 6 46 35 PM" src="https://github.com/facebook/react/assets/63648/6812c24f-e274-4e09-b4de-21deda9ea1d4"> To get the best stack you have to expand the little icon and the regular stack is noisy [due to this Chrome bug](https://issues.chromium.org/issues/345248263) which makes it a little harder to find but once that's fixed it might be easier.
1 parent ffec9ec commit e02baf6

12 files changed

+250
-183
lines changed

Diff for: packages/react-client/src/__tests__/ReactFlight-test.js

+15-7
Original file line numberDiff line numberDiff line change
@@ -692,14 +692,22 @@ describe('ReactFlight', () => {
692692

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

695-
await act(async () => {
696-
const rootModel = await ReactNoopFlightClient.read(transport);
697-
ReactNoop.render(rootModel);
698-
});
699-
expect(ReactNoop).toMatchRenderedOutput('Loading...');
700-
spyOnDevAndProd(console, 'error').mockImplementation(() => {});
701695
await load();
702-
expect(console.error).toHaveBeenCalledTimes(1);
696+
697+
await expect(async () => {
698+
await act(async () => {
699+
const rootModel = await ReactNoopFlightClient.read(transport);
700+
ReactNoop.render(rootModel);
701+
});
702+
}).rejects.toThrow(
703+
__DEV__
704+
? 'Element type is invalid: expected a string (for built-in components) or a class/function ' +
705+
'(for composite components) but got: <div />. ' +
706+
'Did you accidentally export a JSX literal instead of a component?'
707+
: 'Element type is invalid: expected a string (for built-in components) or a class/function ' +
708+
'(for composite components) but got: object.',
709+
);
710+
expect(ReactNoop).toMatchRenderedOutput(null);
703711
});
704712

705713
it('can render a lazy element', async () => {

Diff for: packages/react-dom/src/__tests__/ReactComponent-test.js

+58-26
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ let ReactDOM;
1414
let ReactDOMClient;
1515
let ReactDOMServer;
1616
let act;
17+
let assertConsoleErrorDev;
1718

1819
describe('ReactComponent', () => {
1920
beforeEach(() => {
@@ -24,6 +25,8 @@ describe('ReactComponent', () => {
2425
ReactDOMClient = require('react-dom/client');
2526
ReactDOMServer = require('react-dom/server');
2627
act = require('internal-test-utils').act;
28+
assertConsoleErrorDev =
29+
require('internal-test-utils').assertConsoleErrorDev;
2730
});
2831

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

132135
// @gate !disableStringRefs
133136
it('string refs do not detach and reattach on every render', async () => {
134-
spyOnDev(console, 'error').mockImplementation(() => {});
135-
136137
let refVal;
137138
class Child extends React.Component {
138139
componentDidUpdate() {
@@ -171,6 +172,8 @@ describe('ReactComponent', () => {
171172
root.render(<Parent />);
172173
});
173174

175+
assertConsoleErrorDev(['contains the string ref']);
176+
174177
expect(refVal).toBe(undefined);
175178
await act(() => {
176179
root.render(<Parent showChild={true} />);
@@ -511,19 +514,25 @@ describe('ReactComponent', () => {
511514
});
512515

513516
it('throws usefully when rendering badly-typed elements', async () => {
517+
const container = document.createElement('div');
518+
const root = ReactDOMClient.createRoot(container);
519+
514520
const X = undefined;
515-
let container = document.createElement('div');
516-
let root = ReactDOMClient.createRoot(container);
517-
await expect(
518-
expect(async () => {
519-
await act(() => {
520-
root.render(<X />);
521-
});
522-
}).toErrorDev(
523-
'React.jsx: type is invalid -- expected a string (for built-in components) ' +
524-
'or a class/function (for composite components) but got: undefined.',
525-
),
526-
).rejects.toThrowError(
521+
const XElement = <X />;
522+
if (gate(flags => !flags.enableOwnerStacks)) {
523+
assertConsoleErrorDev(
524+
[
525+
'React.jsx: type is invalid -- expected a string (for built-in components) ' +
526+
'or a class/function (for composite components) but got: undefined.',
527+
],
528+
{withoutStack: true},
529+
);
530+
}
531+
await expect(async () => {
532+
await act(() => {
533+
root.render(XElement);
534+
});
535+
}).rejects.toThrowError(
527536
'Element type is invalid: expected a string (for built-in components) ' +
528537
'or a class/function (for composite components) but got: undefined.' +
529538
(__DEV__
@@ -533,21 +542,44 @@ describe('ReactComponent', () => {
533542
);
534543

535544
const Y = null;
536-
container = document.createElement('div');
537-
root = ReactDOMClient.createRoot(container);
538-
await expect(
539-
expect(async () => {
540-
await act(() => {
541-
root.render(<Y />);
542-
});
543-
}).toErrorDev(
544-
'React.jsx: type is invalid -- expected a string (for built-in components) ' +
545-
'or a class/function (for composite components) but got: null.',
546-
),
547-
).rejects.toThrowError(
545+
const YElement = <Y />;
546+
if (gate(flags => !flags.enableOwnerStacks)) {
547+
assertConsoleErrorDev(
548+
[
549+
'React.jsx: type is invalid -- expected a string (for built-in components) ' +
550+
'or a class/function (for composite components) but got: null.',
551+
],
552+
{withoutStack: true},
553+
);
554+
}
555+
await expect(async () => {
556+
await act(() => {
557+
root.render(YElement);
558+
});
559+
}).rejects.toThrowError(
548560
'Element type is invalid: expected a string (for built-in components) ' +
549561
'or a class/function (for composite components) but got: null.',
550562
);
563+
564+
const Z = true;
565+
const ZElement = <Z />;
566+
if (gate(flags => !flags.enableOwnerStacks)) {
567+
assertConsoleErrorDev(
568+
[
569+
'React.jsx: type is invalid -- expected a string (for built-in components) ' +
570+
'or a class/function (for composite components) but got: boolean.',
571+
],
572+
{withoutStack: true},
573+
);
574+
}
575+
await expect(async () => {
576+
await act(() => {
577+
root.render(ZElement);
578+
});
579+
}).rejects.toThrowError(
580+
'Element type is invalid: expected a string (for built-in components) ' +
581+
'or a class/function (for composite components) but got: boolean.',
582+
);
551583
});
552584

553585
it('includes owner name in the error about badly-typed elements', async () => {

Diff for: packages/react-dom/src/__tests__/ReactDOMServerIntegrationElements-test.js

+19-13
Original file line numberDiff line numberDiff line change
@@ -987,11 +987,13 @@ describe('ReactDOMServerIntegration', () => {
987987
expect(() => {
988988
EmptyComponent = <EmptyComponent />;
989989
}).toErrorDev(
990-
'React.jsx: type is invalid -- expected a string ' +
991-
'(for built-in components) or a class/function (for composite ' +
992-
'components) but got: object. You likely forgot to export your ' +
993-
"component from the file it's defined in, or you might have mixed up " +
994-
'default and named imports.',
990+
gate(flags => flags.enableOwnerStacks)
991+
? []
992+
: 'React.jsx: type is invalid -- expected a string ' +
993+
'(for built-in components) or a class/function (for composite ' +
994+
'components) but got: object. You likely forgot to export your ' +
995+
"component from the file it's defined in, or you might have mixed up " +
996+
'default and named imports.',
995997
{withoutStack: true},
996998
);
997999
await render(EmptyComponent);
@@ -1011,9 +1013,11 @@ describe('ReactDOMServerIntegration', () => {
10111013
expect(() => {
10121014
NullComponent = <NullComponent />;
10131015
}).toErrorDev(
1014-
'React.jsx: type is invalid -- expected a string ' +
1015-
'(for built-in components) or a class/function (for composite ' +
1016-
'components) but got: null.',
1016+
gate(flags => flags.enableOwnerStacks)
1017+
? []
1018+
: 'React.jsx: type is invalid -- expected a string ' +
1019+
'(for built-in components) or a class/function (for composite ' +
1020+
'components) but got: null.',
10171021
{withoutStack: true},
10181022
);
10191023
await render(NullComponent);
@@ -1029,11 +1033,13 @@ describe('ReactDOMServerIntegration', () => {
10291033
expect(() => {
10301034
UndefinedComponent = <UndefinedComponent />;
10311035
}).toErrorDev(
1032-
'React.jsx: type is invalid -- expected a string ' +
1033-
'(for built-in components) or a class/function (for composite ' +
1034-
'components) but got: undefined. You likely forgot to export your ' +
1035-
"component from the file it's defined in, or you might have mixed up " +
1036-
'default and named imports.',
1036+
gate(flags => flags.enableOwnerStacks)
1037+
? []
1038+
: 'React.jsx: type is invalid -- expected a string ' +
1039+
'(for built-in components) or a class/function (for composite ' +
1040+
'components) but got: undefined. You likely forgot to export your ' +
1041+
"component from the file it's defined in, or you might have mixed up " +
1042+
'default and named imports.',
10371043
{withoutStack: true},
10381044
);
10391045

Diff for: packages/react-dom/src/__tests__/ReactLegacyErrorBoundaries-test.internal.js

+33-24
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ let PropTypes;
1313
let React;
1414
let ReactDOM;
1515
let act;
16+
let assertConsoleErrorDev;
1617

1718
// TODO: Refactor this test once componentDidCatch setState is deprecated.
1819
describe('ReactLegacyErrorBoundaries', () => {
@@ -42,6 +43,8 @@ describe('ReactLegacyErrorBoundaries', () => {
4243
ReactDOM = require('react-dom');
4344
React = require('react');
4445
act = require('internal-test-utils').act;
46+
assertConsoleErrorDev =
47+
require('internal-test-utils').assertConsoleErrorDev;
4548

4649
log = [];
4750

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

21012104
await expect(async () => {
2102-
await expect(async () => {
2103-
const container = document.createElement('div');
2104-
await act(() => {
2105-
ReactDOM.render(<X />, container);
2106-
});
2107-
}).rejects.toThrow('got: null');
2108-
}).toErrorDev(
2109-
'React.jsx: type is invalid -- expected a string ' +
2110-
'(for built-in components) or a class/function ' +
2111-
'(for composite components) but got: null.',
2112-
{withoutStack: 1},
2113-
);
2105+
const container = document.createElement('div');
2106+
await act(() => {
2107+
ReactDOM.render(<X />, container);
2108+
});
2109+
}).rejects.toThrow('got: null');
2110+
if (gate(flags => !flags.enableOwnerStacks)) {
2111+
assertConsoleErrorDev(
2112+
[
2113+
'React.jsx: type is invalid -- expected a string ' +
2114+
'(for built-in components) or a class/function ' +
2115+
'(for composite components) but got: null.',
2116+
],
2117+
{withoutStack: true},
2118+
);
2119+
}
21142120

21152121
await expect(async () => {
2116-
await expect(async () => {
2117-
const container = document.createElement('div');
2118-
await act(() => {
2119-
ReactDOM.render(<Y />, container);
2120-
});
2121-
}).rejects.toThrow('got: undefined');
2122-
}).toErrorDev(
2123-
'React.jsx: type is invalid -- expected a string ' +
2124-
'(for built-in components) or a class/function ' +
2125-
'(for composite components) but got: undefined.',
2126-
{withoutStack: 1},
2127-
);
2122+
const container = document.createElement('div');
2123+
await act(() => {
2124+
ReactDOM.render(<Y />, container);
2125+
});
2126+
}).rejects.toThrow('got: undefined');
2127+
if (gate(flags => !flags.enableOwnerStacks)) {
2128+
assertConsoleErrorDev(
2129+
[
2130+
'React.jsx: type is invalid -- expected a string ' +
2131+
'(for built-in components) or a class/function ' +
2132+
'(for composite components) but got: undefined.',
2133+
],
2134+
{withoutStack: true},
2135+
);
2136+
}
21282137
});
21292138

21302139
// @gate !disableLegacyMode

Diff for: packages/react-reconciler/src/ReactChildFiber.js

+6
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,9 @@ function validateFragmentProps(
220220
// For unkeyed root fragments there's no Fiber. We create a fake one just for
221221
// error stack handling.
222222
fiber = createFiberFromElement(element, returnFiber.mode, 0);
223+
if (__DEV__) {
224+
fiber._debugInfo = currentDebugInfo;
225+
}
223226
fiber.return = returnFiber;
224227
}
225228
runWithFiberInDEV(
@@ -242,6 +245,9 @@ function validateFragmentProps(
242245
// For unkeyed root fragments there's no Fiber. We create a fake one just for
243246
// error stack handling.
244247
fiber = createFiberFromElement(element, returnFiber.mode, 0);
248+
if (__DEV__) {
249+
fiber._debugInfo = currentDebugInfo;
250+
}
245251
fiber.return = returnFiber;
246252
}
247253
runWithFiberInDEV(fiber, () => {

Diff for: packages/react-reconciler/src/ReactFiber.js

+9-1
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,7 @@ export function createHostRootFiber(
485485
return createFiber(HostRoot, null, null, mode);
486486
}
487487

488+
// TODO: Get rid of this helper. Only createFiberFromElement should exist.
488489
export function createFiberFromTypeAndProps(
489490
type: any, // React$ElementType
490491
key: null | string,
@@ -650,11 +651,18 @@ export function createFiberFromTypeAndProps(
650651
typeString = type === null ? 'null' : typeof type;
651652
}
652653
653-
throw new Error(
654+
// The type is invalid but it's conceptually a child that errored and not the
655+
// current component itself so we create a virtual child that throws in its
656+
// begin phase. This is the same thing we do in ReactChildFiber if we throw
657+
// but we do it here so that we can assign the debug owner and stack from the
658+
// element itself. That way the error stack will point to the JSX callsite.
659+
fiberTag = Throw;
660+
pendingProps = new Error(
654661
'Element type is invalid: expected a string (for built-in ' +
655662
'components) or a class/function (for composite components) ' +
656663
`but got: ${typeString}.${info}`,
657664
);
665+
resolvedType = null;
658666
}
659667
}
660668
}

Diff for: packages/react-reconciler/src/__tests__/ErrorBoundaryReconciliation-test.internal.js

+14-9
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,16 @@ describe('ErrorBoundaryReconciliation', () => {
66
let ReactTestRenderer;
77
let span;
88
let act;
9+
let assertConsoleErrorDev;
910

1011
beforeEach(() => {
1112
jest.resetModules();
1213

1314
ReactTestRenderer = require('react-test-renderer');
1415
React = require('react');
1516
act = require('internal-test-utils').act;
17+
assertConsoleErrorDev =
18+
require('internal-test-utils').assertConsoleErrorDev;
1619
DidCatchErrorBoundary = class extends React.Component {
1720
state = {error: null};
1821
componentDidCatch(error) {
@@ -58,15 +61,17 @@ describe('ErrorBoundaryReconciliation', () => {
5861
);
5962
});
6063
expect(renderer).toMatchRenderedOutput(<span prop="BrokenRender" />);
61-
await expect(async () => {
62-
await act(() => {
63-
renderer.update(
64-
<ErrorBoundary fallbackTagName={fallbackTagName}>
65-
<BrokenRender fail={true} />
66-
</ErrorBoundary>,
67-
);
68-
});
69-
}).toErrorDev(['invalid', 'invalid']);
64+
await act(() => {
65+
renderer.update(
66+
<ErrorBoundary fallbackTagName={fallbackTagName}>
67+
<BrokenRender fail={true} />
68+
</ErrorBoundary>,
69+
);
70+
});
71+
if (gate(flags => !flags.enableOwnerStacks)) {
72+
assertConsoleErrorDev(['invalid', 'invalid']);
73+
}
74+
7075
const Fallback = fallbackTagName;
7176
expect(renderer).toMatchRenderedOutput(<Fallback prop="ErrorBoundary" />);
7277
}

0 commit comments

Comments
 (0)