Skip to content

Commit 7f19699

Browse files
committed
Validate key in Flight
Elements that render Server Components are validated inside Flight. Others pass the validated flag to the client.
1 parent b270750 commit 7f19699

File tree

4 files changed

+140
-21
lines changed

4 files changed

+140
-21
lines changed

packages/react-client/src/ReactFlightClient.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,7 @@ function createElement(
565565
props: mixed,
566566
owner: null | ReactComponentInfo, // DEV-only
567567
stack: null | string, // DEV-only
568+
validated: number, // DEV-only
568569
): React$Element<any> {
569570
let element: any;
570571
if (__DEV__ && enableRefAsProp) {
@@ -616,7 +617,7 @@ function createElement(
616617
configurable: false,
617618
enumerable: false,
618619
writable: true,
619-
value: 1, // This element has already been validated on the server.
620+
value: enableOwnerStacks ? validated : 1, // Whether the element has already been validated on the server.
620621
});
621622
// debugInfo contains Server Component debug information.
622623
Object.defineProperty(element, '_debugInfo', {
@@ -630,7 +631,7 @@ function createElement(
630631
configurable: false,
631632
enumerable: false,
632633
writable: true,
633-
value: {stack: stack},
634+
value: stack,
634635
});
635636
Object.defineProperty(element, '_debugTask', {
636637
configurable: false,
@@ -1034,6 +1035,7 @@ function parseModelTuple(
10341035
tuple[3],
10351036
__DEV__ ? (tuple: any)[4] : null,
10361037
__DEV__ && enableOwnerStacks ? (tuple: any)[5] : null,
1038+
__DEV__ && enableOwnerStacks ? (tuple: any)[6] : 0,
10371039
);
10381040
}
10391041
return value;

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

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1389,19 +1389,40 @@ describe('ReactFlight', () => {
13891389
ReactNoopFlightClient.read(transport);
13901390
});
13911391

1392-
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 () => {
13931407
function ParentClient({children}) {
13941408
return children;
13951409
}
13961410
const Parent = clientReference(ParentClient);
1397-
expect(() => {
1411+
await expect(async () => {
13981412
const transport = ReactNoopFlightServer.render(
13991413
<Parent>{Array(6).fill(<div>no key</div>)}</Parent>,
14001414
);
14011415
ReactNoopFlightClient.read(transport);
1416+
await act(async () => {
1417+
ReactNoop.render(await ReactNoopFlightClient.read(transport));
1418+
});
14021419
}).toErrorDev(
1403-
'Each child in a list should have a unique "key" prop. ' +
1404-
'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.',
14051426
);
14061427
});
14071428

@@ -2309,7 +2330,7 @@ describe('ReactFlight', () => {
23092330
}
23102331

23112332
function ThirdPartyFragmentComponent() {
2312-
return [<span>Who</span>, ' ', <span>dis?</span>];
2333+
return [<span key="1">Who</span>, ' ', <span key="2">dis?</span>];
23132334
}
23142335

23152336
function ServerComponent({transport}) {
@@ -2321,7 +2342,7 @@ describe('ReactFlight', () => {
23212342
const promiseComponent = Promise.resolve(<ThirdPartyComponent />);
23222343

23232344
const thirdPartyTransport = ReactNoopFlightServer.render(
2324-
[promiseComponent, lazy, <ThirdPartyFragmentComponent />],
2345+
[promiseComponent, lazy, <ThirdPartyFragmentComponent key="3" />],
23252346
{
23262347
environmentName: 'third-party',
23272348
},
@@ -2413,8 +2434,8 @@ describe('ReactFlight', () => {
24132434
const iteratorPromise = new Promise(r => (resolve = r));
24142435

24152436
async function* ThirdPartyAsyncIterableComponent({item, initial}) {
2416-
yield <span>Who</span>;
2417-
yield <span>dis?</span>;
2437+
yield <span key="1">Who</span>;
2438+
yield <span key="2">dis?</span>;
24182439
resolve();
24192440
}
24202441

packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -596,6 +596,10 @@ describe('ReactFlightDOMBrowser', () => {
596596
}
597597
const Parent = clientExports(ParentClient);
598598
const ParentModule = clientExports({Parent: ParentClient});
599+
600+
const container = document.createElement('div');
601+
const root = ReactDOMClient.createRoot(container);
602+
599603
await expect(async () => {
600604
const stream = ReactServerDOMServer.renderToReadableStream(
601605
<>
@@ -606,11 +610,12 @@ describe('ReactFlightDOMBrowser', () => {
606610
</>,
607611
webpackMap,
608612
);
609-
await ReactServerDOMClient.createFromReadableStream(stream);
610-
}).toErrorDev(
611-
'Each child in a list should have a unique "key" prop. ' +
612-
'See https://react.dev/link/warning-keys for more information.',
613-
);
613+
const result =
614+
await ReactServerDOMClient.createFromReadableStream(stream);
615+
await act(() => {
616+
root.render(result);
617+
});
618+
}).toErrorDev('Each child in a list should have a unique "key" prop.');
614619
});
615620

616621
it('basic use(promise)', async () => {

packages/react-server/src/ReactFlightServer.js

Lines changed: 97 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,7 @@ export type Request = {
399399
onPostpone: (reason: string) => void,
400400
// DEV-only
401401
environmentName: string,
402+
didWarnForKey: null | WeakSet<ReactComponentInfo>,
402403
};
403404

404405
const {
@@ -500,6 +501,7 @@ export function createRequest(
500501
if (__DEV__) {
501502
request.environmentName =
502503
environmentName === undefined ? 'Server' : environmentName;
504+
request.didWarnForKey = null;
503505
}
504506
const rootTask = createTask(request, model, null, false, abortSet);
505507
pingedTasks.push(rootTask);
@@ -965,6 +967,7 @@ function renderFunctionComponent<Props>(
965967
props: Props,
966968
owner: null | ReactComponentInfo, // DEV-only
967969
stack: null | string, // DEV-only
970+
validated: number, // DEV-only
968971
): ReactJSONValue {
969972
// Reset the task's thenable state before continuing, so that if a later
970973
// component suspends we can reuse the same task object. If the same
@@ -1005,6 +1008,10 @@ function renderFunctionComponent<Props>(
10051008
// being no references to this as an owner.
10061009
outlineModel(request, componentDebugInfo);
10071010
emitDebugChunk(request, componentDebugID, componentDebugInfo);
1011+
1012+
if (enableOwnerStacks) {
1013+
warnForMissingKey(request, key, validated, componentDebugInfo);
1014+
}
10081015
}
10091016
prepareToUseHooksForComponent(prevThenableState, componentDebugInfo);
10101017
result = callComponentInDEV(Component, props, componentDebugInfo);
@@ -1102,6 +1109,8 @@ function renderFunctionComponent<Props>(
11021109
if (__DEV__) {
11031110
(result: any)._debugInfo = iterableChild._debugInfo;
11041111
}
1112+
} else if (__DEV__ && (result: any).$$typeof === REACT_ELEMENT_TYPE) {
1113+
(result: any)._store.validated = 1;
11051114
}
11061115
}
11071116
// Track this element's key on the Server Component on the keyPath context..
@@ -1124,11 +1133,68 @@ function renderFunctionComponent<Props>(
11241133
return json;
11251134
}
11261135

1136+
function warnForMissingKey(
1137+
request: Request,
1138+
key: null | string,
1139+
validated: number,
1140+
componentDebugInfo: ReactComponentInfo,
1141+
): void {
1142+
if (__DEV__) {
1143+
if (validated !== 2) {
1144+
return;
1145+
}
1146+
1147+
let didWarnForKey = request.didWarnForKey;
1148+
if (didWarnForKey == null) {
1149+
didWarnForKey = request.didWarnForKey = new WeakSet();
1150+
}
1151+
const parentOwner = componentDebugInfo.owner;
1152+
if (parentOwner != null) {
1153+
if (didWarnForKey.has(parentOwner)) {
1154+
// We already warned for other children in this parent.
1155+
return;
1156+
}
1157+
didWarnForKey.add(parentOwner);
1158+
}
1159+
1160+
// Call with the server component as the currently rendering component
1161+
// for context.
1162+
callComponentInDEV(
1163+
() => {
1164+
console.error(
1165+
'Each child in a list should have a unique "key" prop.' +
1166+
'%s%s See https://react.dev/link/warning-keys for more information.',
1167+
'',
1168+
'',
1169+
);
1170+
},
1171+
null,
1172+
componentDebugInfo,
1173+
);
1174+
}
1175+
}
1176+
11271177
function renderFragment(
11281178
request: Request,
11291179
task: Task,
11301180
children: $ReadOnlyArray<ReactClientValue>,
11311181
): ReactJSONValue {
1182+
if (__DEV__) {
1183+
for (let i = 0; i < children.length; i++) {
1184+
const child = children[i];
1185+
if (
1186+
child !== null &&
1187+
typeof child === 'object' &&
1188+
child.$$typeof === REACT_ELEMENT_TYPE
1189+
) {
1190+
const element: ReactElement = (child: any);
1191+
if (element.key === null && !element._store.validated) {
1192+
element._store.validated = 2;
1193+
}
1194+
}
1195+
}
1196+
}
1197+
11321198
if (task.keyPath !== null) {
11331199
// We have a Server Component that specifies a key but we're now splitting
11341200
// the tree using a fragment.
@@ -1231,6 +1297,7 @@ function renderClientElement(
12311297
props: any,
12321298
owner: null | ReactComponentInfo, // DEV-only
12331299
stack: null | string, // DEV-only
1300+
validated: number, // DEV-only
12341301
): ReactJSONValue {
12351302
// We prepend the terminal client element that actually gets serialized with
12361303
// the keys of any Server Components which are not serialized.
@@ -1242,7 +1309,7 @@ function renderClientElement(
12421309
}
12431310
const element = __DEV__
12441311
? enableOwnerStacks
1245-
? [REACT_ELEMENT_TYPE, type, key, props, owner, stack]
1312+
? [REACT_ELEMENT_TYPE, type, key, props, owner, stack, validated]
12461313
: [REACT_ELEMENT_TYPE, type, key, props, owner]
12471314
: [REACT_ELEMENT_TYPE, type, key, props];
12481315
if (task.implicitSlot && key !== null) {
@@ -1292,6 +1359,7 @@ function renderElement(
12921359
props: any,
12931360
owner: null | ReactComponentInfo, // DEV only
12941361
stack: null | string, // DEV only
1362+
validated: number, // DEV only
12951363
): ReactJSONValue {
12961364
if (ref !== null && ref !== undefined) {
12971365
// When the ref moves to the regular props object this will implicitly
@@ -1312,7 +1380,15 @@ function renderElement(
13121380
if (typeof type === 'function') {
13131381
if (isClientReference(type) || isOpaqueTemporaryReference(type)) {
13141382
// This is a reference to a Client Component.
1315-
return renderClientElement(task, type, key, props, owner, stack);
1383+
return renderClientElement(
1384+
task,
1385+
type,
1386+
key,
1387+
props,
1388+
owner,
1389+
stack,
1390+
validated,
1391+
);
13161392
}
13171393
// This is a Server Component.
13181394
return renderFunctionComponent(
@@ -1323,10 +1399,11 @@ function renderElement(
13231399
props,
13241400
owner,
13251401
stack,
1402+
validated,
13261403
);
13271404
} else if (typeof type === 'string') {
13281405
// This is a host element. E.g. HTML.
1329-
return renderClientElement(task, type, key, props, owner, stack);
1406+
return renderClientElement(task, type, key, props, owner, stack, validated);
13301407
} else if (typeof type === 'symbol') {
13311408
if (type === REACT_FRAGMENT_TYPE && key === null) {
13321409
// For key-less fragments, we add a small optimization to avoid serializing
@@ -1347,11 +1424,19 @@ function renderElement(
13471424
}
13481425
// This might be a built-in React component. We'll let the client decide.
13491426
// Any built-in works as long as its props are serializable.
1350-
return renderClientElement(task, type, key, props, owner, stack);
1427+
return renderClientElement(task, type, key, props, owner, stack, validated);
13511428
} else if (type != null && typeof type === 'object') {
13521429
if (isClientReference(type)) {
13531430
// This is a reference to a Client Component.
1354-
return renderClientElement(task, type, key, props, owner, stack);
1431+
return renderClientElement(
1432+
task,
1433+
type,
1434+
key,
1435+
props,
1436+
owner,
1437+
stack,
1438+
validated,
1439+
);
13551440
}
13561441
switch (type.$$typeof) {
13571442
case REACT_LAZY_TYPE: {
@@ -1372,6 +1457,7 @@ function renderElement(
13721457
props,
13731458
owner,
13741459
stack,
1460+
validated,
13751461
);
13761462
}
13771463
case REACT_FORWARD_REF_TYPE: {
@@ -1383,6 +1469,7 @@ function renderElement(
13831469
props,
13841470
owner,
13851471
stack,
1472+
validated,
13861473
);
13871474
}
13881475
case REACT_MEMO_TYPE: {
@@ -1395,6 +1482,7 @@ function renderElement(
13951482
props,
13961483
owner,
13971484
stack,
1485+
validated,
13981486
);
13991487
}
14001488
}
@@ -1963,8 +2051,11 @@ function renderModelDestructive(
19632051
props,
19642052
__DEV__ ? element._owner : null,
19652053
__DEV__ && enableOwnerStacks
1966-
? filterDebugStack(element._debugStack)
2054+
? !element._debugStack || typeof element._debugStack === 'string'
2055+
? element._debugStack
2056+
: filterDebugStack(element._debugStack)
19672057
: null,
2058+
__DEV__ && enableOwnerStacks ? element._store.validated : 0,
19682059
);
19692060
}
19702061
case REACT_LAZY_TYPE: {

0 commit comments

Comments
 (0)