Skip to content

Commit 84239da

Browse files
authored
Move createElement/JSX Warnings into the Renderer (#29088)
This is necessary to simplify the component stack handling to make way for owner stacks. It also solves some hacks that we used to have but don't quite make sense. It also solves the problem where things like key warnings get silenced in RSC because they get deduped. It also surfaces areas where we were missing key warnings to begin with. Almost every type of warning is issued from the renderer. React Elements are really not anything special themselves. They're just lazily invoked functions and its really the renderer that determines there semantics. We have three types of warnings that previously fired in JSX/createElement: - Fragment props validation. - Type validation. - Key warning. It's nice to be able to do some validation in the JSX/createElement because it has a more specific stack frame at the callsite. However, that's the case for every type of component and validation. That's the whole point of enableOwnerStacks. It's also not sufficient to do it in JSX/createElement so we also have validation in the renderers too. So this validation is really just an eager validation but also happens again later. The problem with these is that we don't really know what types are valid until we get to the renderer. Additionally, by placing it in the isomorphic code it becomes harder to do deduping of warnings in a way that makes sense for that renderer. It also means we can't reuse logic for managing stacks etc. Fragment props validation really should just be part of the renderer like any other component type. This also matters once we add Fragment refs and other fragment features. So I moved this into Fiber. However, since some Fragments don't have Fibers, I do the validation in ChildFiber instead of beginWork where it would normally happen. For `type` validation we already do validation when rendering. By leaving it to the renderer we don't have to hard code an extra list. This list also varies by context. E.g. class components aren't allowed in RSC but client references are but we don't have an isomorphic way to identify client references because they're defined by the host config so the current logic is flawed anyway. I kept the early validation for now without the `enableOwnerStacks` since it does provide a nicer stack frame but with that flag on it'll be handled with nice stacks anyway. I normalized some of the errors to ensure tests pass. For `key` validation it's the same principle. The mechanism for the heuristic is still the same - if it passes statically through a parent JSX/createElement call then it's considered validated. We already did print the error later from the renderer so this also disables the early log in the `enableOwnerStacks` flag. I also added logging to Fizz so that key warnings can print in SSR logs. Flight is a bit more complex. For elements that end up on the client we just pass the `validated` flag along to the client and let the client renderer print the error once rendered. For server components we log the error from Flight with the server component as the owner on the stack which will allow us to print the right stack for context. The factoring of this is a little tricky because we only want to warn if it's in an array parent but we want to log the error later to get the right debug info. Fiber/Fizz has a similar factoring problem that causes us to create a fake Fiber for the owner which means the logs won't be associated with the right place in DevTools.
1 parent 5fe8c0b commit 84239da

31 files changed

+874
-384
lines changed

fixtures/flight/server/region.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,17 +81,20 @@ async function renderApp(res, returnValue, formState) {
8181
).main.css;
8282
}
8383
const App = m.default.default || m.default;
84-
const root = [
84+
const root = React.createElement(
85+
React.Fragment,
86+
null,
8587
// Prepend the App's tree with stylesheets required for this entrypoint.
8688
mainCSSChunks.map(filename =>
8789
React.createElement('link', {
8890
rel: 'stylesheet',
8991
href: filename,
9092
precedence: 'default',
93+
key: filename,
9194
})
9295
),
93-
React.createElement(App),
94-
];
96+
React.createElement(App)
97+
);
9598
// For client-invoked server actions we refresh the tree and return a return value.
9699
const payload = {root, returnValue, formState};
97100
const {pipe} = renderToPipeableStream(payload, moduleMap);

packages/react-client/src/ReactFlightClient.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,7 @@ function createElement(
579579
props: mixed,
580580
owner: null | ReactComponentInfo, // DEV-only
581581
stack: null | string, // DEV-only
582+
validated: number, // DEV-only
582583
): React$Element<any> {
583584
let element: any;
584585
if (__DEV__ && enableRefAsProp) {
@@ -624,13 +625,13 @@ function createElement(
624625
// Unfortunately, _store is enumerable in jest matchers so for equality to
625626
// work, I need to keep it or make _store non-enumerable in the other file.
626627
element._store = ({}: {
627-
validated?: boolean,
628+
validated?: number,
628629
});
629630
Object.defineProperty(element._store, 'validated', {
630631
configurable: false,
631632
enumerable: false,
632633
writable: true,
633-
value: true, // This element has already been validated on the server.
634+
value: enableOwnerStacks ? validated : 1, // Whether the element has already been validated on the server.
634635
});
635636
// debugInfo contains Server Component debug information.
636637
Object.defineProperty(element, '_debugInfo', {
@@ -644,7 +645,7 @@ function createElement(
644645
configurable: false,
645646
enumerable: false,
646647
writable: true,
647-
value: {stack: stack},
648+
value: stack,
648649
});
649650
Object.defineProperty(element, '_debugTask', {
650651
configurable: false,
@@ -1053,6 +1054,7 @@ function parseModelTuple(
10531054
tuple[3],
10541055
__DEV__ ? (tuple: any)[4] : null,
10551056
__DEV__ && enableOwnerStacks ? (tuple: any)[5] : null,
1057+
__DEV__ && enableOwnerStacks ? (tuple: any)[6] : 0,
10561058
);
10571059
}
10581060
return value;

packages/react-client/src/__tests__/ReactFlight-test.js

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1143,6 +1143,9 @@ describe('ReactFlight', () => {
11431143
'\n' +
11441144
'Check the render method of `Component`. See https://react.dev/link/warning-keys for more information.\n' +
11451145
' in span (at **)\n' +
1146+
// TODO: Because this validates after the div has been mounted, it is part of
1147+
// the parent stack but since owner stacks will switch to owners this goes away again.
1148+
(gate(flags => flags.enableOwnerStacks) ? ' in div (at **)\n' : '') +
11461149
' in Component (at **)\n' +
11471150
' in Indirection (at **)\n' +
11481151
' in App (at **)',
@@ -1386,19 +1389,40 @@ describe('ReactFlight', () => {
13861389
ReactNoopFlightClient.read(transport);
13871390
});
13881391

1389-
it('should warn in DEV a child is missing keys', () => {
1392+
it('should warn in DEV a child is missing keys on server component', () => {
1393+
function NoKey({children}) {
1394+
return <div key="this has a key but parent doesn't" />;
1395+
}
1396+
expect(() => {
1397+
const transport = ReactNoopFlightServer.render(
1398+
<div>{Array(6).fill(<NoKey />)}</div>,
1399+
);
1400+
ReactNoopFlightClient.read(transport);
1401+
}).toErrorDev('Each child in a list should have a unique "key" prop.', {
1402+
withoutStack: gate(flags => flags.enableOwnerStacks),
1403+
});
1404+
});
1405+
1406+
it('should warn in DEV a child is missing keys in client component', async () => {
13901407
function ParentClient({children}) {
13911408
return children;
13921409
}
13931410
const Parent = clientReference(ParentClient);
1394-
expect(() => {
1411+
await expect(async () => {
13951412
const transport = ReactNoopFlightServer.render(
13961413
<Parent>{Array(6).fill(<div>no key</div>)}</Parent>,
13971414
);
13981415
ReactNoopFlightClient.read(transport);
1416+
await act(async () => {
1417+
ReactNoop.render(await ReactNoopFlightClient.read(transport));
1418+
});
13991419
}).toErrorDev(
1400-
'Each child in a list should have a unique "key" prop. ' +
1401-
'See https://react.dev/link/warning-keys for more information.',
1420+
gate(flags => flags.enableOwnerStacks)
1421+
? 'Each child in a list should have a unique "key" prop.' +
1422+
'\n\nCheck the top-level render call using <ParentClient>. ' +
1423+
'See https://react.dev/link/warning-keys for more information.'
1424+
: 'Each child in a list should have a unique "key" prop. ' +
1425+
'See https://react.dev/link/warning-keys for more information.',
14021426
);
14031427
});
14041428

@@ -2306,7 +2330,7 @@ describe('ReactFlight', () => {
23062330
}
23072331

23082332
function ThirdPartyFragmentComponent() {
2309-
return [<span>Who</span>, ' ', <span>dis?</span>];
2333+
return [<span key="1">Who</span>, ' ', <span key="2">dis?</span>];
23102334
}
23112335

23122336
function ServerComponent({transport}) {
@@ -2318,7 +2342,7 @@ describe('ReactFlight', () => {
23182342
const promiseComponent = Promise.resolve(<ThirdPartyComponent />);
23192343

23202344
const thirdPartyTransport = ReactNoopFlightServer.render(
2321-
[promiseComponent, lazy, <ThirdPartyFragmentComponent />],
2345+
[promiseComponent, lazy, <ThirdPartyFragmentComponent key="3" />],
23222346
{
23232347
environmentName: 'third-party',
23242348
},
@@ -2410,8 +2434,8 @@ describe('ReactFlight', () => {
24102434
const iteratorPromise = new Promise(r => (resolve = r));
24112435

24122436
async function* ThirdPartyAsyncIterableComponent({item, initial}) {
2413-
yield <span>Who</span>;
2414-
yield <span>dis?</span>;
2437+
yield <span key="1">Who</span>;
2438+
yield <span key="2">dis?</span>;
24152439
resolve();
24162440
}
24172441

packages/react-devtools-shared/src/__tests__/inspectedElement-test.js

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2562,13 +2562,7 @@ describe('InspectedElement', () => {
25622562
const data = await getErrorsAndWarningsForElementAtIndex(0);
25632563
expect(data).toMatchInlineSnapshot(`
25642564
{
2565-
"errors": [
2566-
[
2567-
"Warning: Each child in a list should have a unique "key" prop. See https://react.dev/link/warning-keys for more information.
2568-
at Example",
2569-
1,
2570-
],
2571-
],
2565+
"errors": [],
25722566
"warnings": [],
25732567
}
25742568
`);

packages/react-devtools-shared/src/__tests__/store-test.js

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1932,9 +1932,8 @@ describe('Store', () => {
19321932
);
19331933

19341934
expect(store).toMatchInlineSnapshot(`
1935-
✕ 1, ⚠ 0
19361935
[root]
1937-
▾ <Example>
1936+
▾ <Example>
19381937
<Child>
19391938
`);
19401939
});

packages/react-devtools-shared/src/backend/DevToolsComponentStackFrame.js

Lines changed: 0 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -12,22 +12,8 @@
1212
// while still maintaining support for multiple renderer versions
1313
// (which use different values for ReactTypeOfWork).
1414

15-
import type {LazyComponent} from 'react/src/ReactLazy';
1615
import type {CurrentDispatcherRef} from './types';
1716

18-
import {
19-
FORWARD_REF_NUMBER,
20-
FORWARD_REF_SYMBOL_STRING,
21-
LAZY_NUMBER,
22-
LAZY_SYMBOL_STRING,
23-
MEMO_NUMBER,
24-
MEMO_SYMBOL_STRING,
25-
SUSPENSE_NUMBER,
26-
SUSPENSE_SYMBOL_STRING,
27-
SUSPENSE_LIST_NUMBER,
28-
SUSPENSE_LIST_SYMBOL_STRING,
29-
} from './ReactSymbols';
30-
3117
// The shared console patching code is DEV-only.
3218
// We can't use it since DevTools only ships production builds.
3319
import {disableLogs, reenableLogs} from './DevToolsConsolePatching';
@@ -297,69 +283,3 @@ export function describeFunctionComponentFrame(
297283
): string {
298284
return describeNativeComponentFrame(fn, false, currentDispatcherRef);
299285
}
300-
301-
function shouldConstruct(Component: Function) {
302-
const prototype = Component.prototype;
303-
return !!(prototype && prototype.isReactComponent);
304-
}
305-
306-
export function describeUnknownElementTypeFrameInDEV(
307-
type: any,
308-
currentDispatcherRef: CurrentDispatcherRef,
309-
): string {
310-
if (!__DEV__) {
311-
return '';
312-
}
313-
if (type == null) {
314-
return '';
315-
}
316-
if (typeof type === 'function') {
317-
return describeNativeComponentFrame(
318-
type,
319-
shouldConstruct(type),
320-
currentDispatcherRef,
321-
);
322-
}
323-
if (typeof type === 'string') {
324-
return describeBuiltInComponentFrame(type);
325-
}
326-
switch (type) {
327-
case SUSPENSE_NUMBER:
328-
case SUSPENSE_SYMBOL_STRING:
329-
return describeBuiltInComponentFrame('Suspense');
330-
case SUSPENSE_LIST_NUMBER:
331-
case SUSPENSE_LIST_SYMBOL_STRING:
332-
return describeBuiltInComponentFrame('SuspenseList');
333-
}
334-
if (typeof type === 'object') {
335-
switch (type.$$typeof) {
336-
case FORWARD_REF_NUMBER:
337-
case FORWARD_REF_SYMBOL_STRING:
338-
return describeFunctionComponentFrame(
339-
type.render,
340-
currentDispatcherRef,
341-
);
342-
case MEMO_NUMBER:
343-
case MEMO_SYMBOL_STRING:
344-
// Memo may contain any component type so we recursively resolve it.
345-
return describeUnknownElementTypeFrameInDEV(
346-
type.type,
347-
currentDispatcherRef,
348-
);
349-
case LAZY_NUMBER:
350-
case LAZY_SYMBOL_STRING: {
351-
const lazyComponent: LazyComponent<any, any> = (type: any);
352-
const payload = lazyComponent._payload;
353-
const init = lazyComponent._init;
354-
try {
355-
// Lazy may contain any component type so we recursively resolve it.
356-
return describeUnknownElementTypeFrameInDEV(
357-
init(payload),
358-
currentDispatcherRef,
359-
);
360-
} catch (x) {}
361-
}
362-
}
363-
}
364-
return '';
365-
}

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

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1833,10 +1833,18 @@ describe('ReactDOMFizzServer', () => {
18331833
expect(mockError).toHaveBeenCalledWith(
18341834
'Warning: Each child in a list should have a unique "key" prop.%s%s' +
18351835
' See https://react.dev/link/warning-keys for more information.%s',
1836-
'\n\nCheck the top-level render call using <div>.',
1836+
gate(flags => flags.enableOwnerStacks)
1837+
? // We currently don't track owners in Fizz which is responsible for this frame.
1838+
''
1839+
: '\n\nCheck the top-level render call using <div>.',
18371840
'',
18381841
'\n' +
18391842
' in span (at **)\n' +
1843+
// TODO: Because this validates after the div has been mounted, it is part of
1844+
// the parent stack but since owner stacks will switch to owners this goes away again.
1845+
(gate(flags => flags.enableOwnerStacks)
1846+
? ' in div (at **)\n'
1847+
: '') +
18401848
' in B (at **)\n' +
18411849
' in Suspense (at **)\n' +
18421850
' in div (at **)\n' +
@@ -1890,7 +1898,12 @@ describe('ReactDOMFizzServer', () => {
18901898
</b>
18911899
);
18921900
if (this.props.prefix) {
1893-
return [readText(this.props.prefix), child];
1901+
return (
1902+
<>
1903+
{readText(this.props.prefix)}
1904+
{child}
1905+
</>
1906+
);
18941907
}
18951908
return child;
18961909
}
@@ -1900,7 +1913,13 @@ describe('ReactDOMFizzServer', () => {
19001913
const {pipe} = renderToPipeableStream(
19011914
<TestProvider ctx="A">
19021915
<div>
1903-
<Suspense fallback={[<Text text="Loading: " />, <TestConsumer />]}>
1916+
<Suspense
1917+
fallback={
1918+
<>
1919+
<Text text="Loading: " />
1920+
<TestConsumer />
1921+
</>
1922+
}>
19041923
<TestProvider ctx="B">
19051924
<TestConsumer prefix="Hello: " />
19061925
</TestProvider>

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -819,7 +819,7 @@ describe('ReactDOMServer', () => {
819819
}
820820

821821
function Child() {
822-
return [<A key="1" />, <B key="2" />, <span ariaTypo2="no" />];
822+
return [<A key="1" />, <B key="2" />, <span ariaTypo2="no" key="3" />];
823823
}
824824

825825
function App() {

0 commit comments

Comments
 (0)