Skip to content

Commit 1bbb3e5

Browse files
committed
Mark child for force warning if assigned implicit key
When React.Children assigns an implicit key we should still warn when the original element should've had a key since if was in an array. This marks such elements as having failed validation even if they have a key so that the error can later be logged in the renderer.
1 parent ffbe35d commit 1bbb3e5

File tree

6 files changed

+57
-20
lines changed

6 files changed

+57
-20
lines changed

packages/react-client/src/ReactFlightClient.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -610,13 +610,13 @@ function createElement(
610610
// Unfortunately, _store is enumerable in jest matchers so for equality to
611611
// work, I need to keep it or make _store non-enumerable in the other file.
612612
element._store = ({}: {
613-
validated?: boolean,
613+
validated?: number,
614614
});
615615
Object.defineProperty(element._store, 'validated', {
616616
configurable: false,
617617
enumerable: false,
618618
writable: true,
619-
value: true, // This element has already been validated on the server.
619+
value: 1, // This element has already been validated on the server.
620620
});
621621
// debugInfo contains Server Component debug information.
622622
Object.defineProperty(element, '_debugInfo', {

packages/react-reconciler/src/ReactChildFiber.js

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,11 @@ if (__DEV__) {
114114
if (child === null || typeof child !== 'object') {
115115
return;
116116
}
117-
if (!child._store || child._store.validated || child.key != null) {
117+
if (
118+
!child._store ||
119+
((child._store.validated || child.key != null) &&
120+
child._store.validated !== 2)
121+
) {
118122
return;
119123
}
120124

@@ -126,7 +130,7 @@ if (__DEV__) {
126130
}
127131

128132
// $FlowFixMe[cannot-write] unable to narrow type from mixed to writable object
129-
child._store.validated = true;
133+
child._store.validated = 1;
130134

131135
const componentName = getComponentNameFromFiber(returnFiber);
132136

@@ -171,6 +175,9 @@ if (__DEV__) {
171175
}
172176

173177
// We create a fake Fiber for the child to log the stack trace from.
178+
// TODO: Refactor the warnForMissingKey calls to happen after fiber creation
179+
// so that we can get access to the fiber that will eventually be created.
180+
// That way the log can show up associated with the right instance in DevTools.
174181
const fiber = createFiberFromElement((child: any), returnFiber.mode, 0);
175182
fiber.return = returnFiber;
176183

packages/react/src/ReactChildren.js

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -207,24 +207,38 @@ function mapIntoArray(
207207
// The `if` statement here prevents auto-disabling of the safe
208208
// coercion ESLint rule, so we must manually disable it below.
209209
// $FlowFixMe[incompatible-type] Flow incorrectly thinks React.Portal doesn't have a key
210-
if (mappedChild.key && (!child || child.key !== mappedChild.key)) {
211-
checkKeyStringCoercion(mappedChild.key);
210+
if (mappedChild.key != null) {
211+
if (!child || child.key !== mappedChild.key) {
212+
checkKeyStringCoercion(mappedChild.key);
213+
}
212214
}
213215
}
214-
mappedChild = cloneAndReplaceKey(
216+
const newChild = cloneAndReplaceKey(
215217
mappedChild,
216218
// Keep both the (mapped) and old keys if they differ, just as
217219
// traverseAllChildren used to do for objects as children
218220
escapedPrefix +
219221
// $FlowFixMe[incompatible-type] Flow incorrectly thinks React.Portal doesn't have a key
220-
(mappedChild.key && (!child || child.key !== mappedChild.key)
222+
(mappedChild.key != null &&
223+
(!child || child.key !== mappedChild.key)
221224
? escapeUserProvidedKey(
222225
// $FlowFixMe[unsafe-addition]
223226
'' + mappedChild.key, // eslint-disable-line react-internal/safe-string-coercion
224227
) + '/'
225228
: '') +
226229
childKey,
227230
);
231+
if (__DEV__) {
232+
if (nameSoFar !== '' && mappedChild.key == null) {
233+
// We need to validate that this child should have had a key before assigning it one.
234+
if (!newChild._store.validated) {
235+
// We mark this child as having failed validation but we let the actual renderer
236+
// print the warning later.
237+
newChild._store.validated = 2;
238+
}
239+
}
240+
}
241+
mappedChild = newChild;
228242
}
229243
array.push(mappedChild);
230244
}

packages/react/src/__tests__/ReactChildren-test.js

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,7 @@ describe('ReactChildren', () => {
301301
]);
302302
});
303303

304-
it('should be called for each child in an iterable without keys', () => {
304+
it('should be called for each child in an iterable without keys', async () => {
305305
const threeDivIterable = {
306306
'@@iterator': function () {
307307
let i = 0;
@@ -323,11 +323,6 @@ describe('ReactChildren', () => {
323323
return kid;
324324
});
325325

326-
let instance;
327-
expect(() => (instance = <div>{threeDivIterable}</div>)).toErrorDev(
328-
'Warning: Each child in a list should have a unique "key" prop.',
329-
);
330-
331326
function assertCalls() {
332327
expect(callback).toHaveBeenCalledTimes(3);
333328
expect(callback).toHaveBeenCalledWith(<div />, 0);
@@ -336,7 +331,18 @@ describe('ReactChildren', () => {
336331
callback.mockClear();
337332
}
338333

334+
let instance;
335+
expect(() => {
336+
instance = <div>{threeDivIterable}</div>;
337+
}).toErrorDev(
338+
// With the flag on this doesn't warn eagerly but only when rendered
339+
gate(flag => flag.enableOwnerStacks)
340+
? []
341+
: ['Warning: Each child in a list should have a unique "key" prop.'],
342+
);
343+
339344
React.Children.forEach(instance.props.children, callback, context);
345+
340346
assertCalls();
341347

342348
const mappedChildren = React.Children.map(
@@ -350,6 +356,16 @@ describe('ReactChildren', () => {
350356
<div key=".1" />,
351357
<div key=".2" />,
352358
]);
359+
360+
const container = document.createElement('div');
361+
const root = ReactDOMClient.createRoot(container);
362+
await expect(async () => {
363+
await act(() => {
364+
root.render(instance);
365+
});
366+
}).toErrorDev(
367+
'Warning: Each child in a list should have a unique "key" prop.',
368+
);
353369
});
354370

355371
it('should be called for each child in an iterable with keys', () => {
@@ -953,7 +969,7 @@ describe('ReactChildren', () => {
953969
});
954970

955971
it('should render React.lazy after suspending', async () => {
956-
const lazyElement = React.lazy(async () => ({default: <div />}));
972+
const lazyElement = React.lazy(async () => ({default: <div key="hi" />}));
957973
function Component() {
958974
return React.Children.map([lazyElement], c =>
959975
React.cloneElement(c, {children: 'hi'}),
@@ -969,7 +985,7 @@ describe('ReactChildren', () => {
969985
});
970986

971987
it('should render cached Promises after suspending', async () => {
972-
const promise = Promise.resolve(<div />);
988+
const promise = Promise.resolve(<div key="hi" />);
973989
function Component() {
974990
return React.Children.map([promise], c =>
975991
React.cloneElement(c, {children: 'hi'}),

packages/react/src/jsx/ReactJSXElement.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -342,7 +342,7 @@ function ReactElement(
342342
configurable: false,
343343
enumerable: false,
344344
writable: true,
345-
value: false,
345+
value: 0,
346346
});
347347
// debugInfo contains Server Component debug information.
348348
Object.defineProperty(element, '_debugInfo', {
@@ -1073,7 +1073,7 @@ function validateChildKeys(node, parentType) {
10731073
} else if (isValidElement(node)) {
10741074
// This element was passed in a valid location.
10751075
if (node._store) {
1076-
node._store.validated = true;
1076+
node._store.validated = 1;
10771077
}
10781078
} else {
10791079
const iteratorFn = getIteratorFn(node);
@@ -1133,7 +1133,7 @@ function validateExplicitKey(element, parentType) {
11331133
if (!element._store || element._store.validated || element.key != null) {
11341134
return;
11351135
}
1136-
element._store.validated = true;
1136+
element._store.validated = 1;
11371137

11381138
const currentComponentErrorInfo = getCurrentComponentErrorInfo(parentType);
11391139
if (ownerHasKeyUseWarning[currentComponentErrorInfo]) {

packages/shared/ReactElementType.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export type ReactElement = {
2323
_owner: any,
2424

2525
// __DEV__
26-
_store: {validated: boolean, ...},
26+
_store: {validated: 0 | 1 | 2, ...}, // 0: not validated, 1: validated, 2: force fail
2727
_debugInfo: null | ReactDebugInfo,
2828
_debugStack: Error,
2929
_debugTask: null | ConsoleTask,

0 commit comments

Comments
 (0)