[Flight] Warn for keyless fragments in an array (#30588)
Conceptually this is the same as rendering this as if it was a built-in Server Component.
Sebastian Markbåge committed
Aug 2, 2024 at 20:08 UTC
ba6a9e94edf0db3ad96432804f9931ce9dc89fec
2 files changed
+41
-18
packages/react-client/src/__tests__/ReactFlight-test.js
+19
@@ -1632,6 +1632,25 @@ describe('ReactFlight', () => {
1632
}).toErrorDev('Each child in a list should have a unique "key" prop.');
1633
});
1634
1635
+ // @gate !__DEV__ || enableOwnerStacks
1636
+ it('should warn in DEV a child is missing keys on a fragment', () => {
1637
+ expect(() => {
1638
+ // While we're on the server we need to have the Server version active to track component stacks.
1639
+ jest.resetModules();
1640
+ jest.mock('react', () => ReactServer);
1641
+ const transport = ReactNoopFlightServer.render(
1642
+ ReactServer.createElement(
1643
+ 'div',
1644
+ null,
1645
+ Array(6).fill(ReactServer.createElement(ReactServer.Fragment)),
1646
+ ),
1647
+ );
1648
+ jest.resetModules();
1649
+ jest.mock('react', () => React);
1650
+ ReactNoopFlightClient.read(transport);
1651
+ }).toErrorDev('Each child in a list should have a unique "key" prop.');
1652
+ });
1653
+
1654
it('should warn in DEV a child is missing keys in client component', async () => {
1655
function ParentClient({children}) {
1656
return children;
packages/react-server/src/ReactFlightServer.js
+22
-18
@@ -1029,7 +1029,7 @@ function renderFunctionComponent<Props>(
1029
const componentDebugID = debugID;
1030
const componentName =
1031
(Component: any).displayName || Component.name || '';
1032
- const componentEnv = request.environmentName();
1032
+ const componentEnv = (0, request.environmentName)();
1033
request.pendingChunks++;
1034
componentDebugInfo = ({
1035
name: componentName,
@@ -1056,14 +1056,8 @@ function renderFunctionComponent<Props>(
1056
// We've emitted the latest environment for this task so we track that.
1057
task.environmentName = componentEnv;
1058
1059
- if (enableOwnerStacks) {
1060
- warnForMissingKey(
1061
- request,
1062
- key,
1063
- validated,
1064
- componentDebugInfo,
1065
- task.debugTask,
1066
- );
1059
+ if (enableOwnerStacks && validated === 2) {
1060
+ warnForMissingKey(request, key, componentDebugInfo, task.debugTask);
1061
}
1062
}
1063
prepareToUseHooksForComponent(prevThenableState, componentDebugInfo);
@@ -1256,15 +1250,10 @@ function renderFunctionComponent<Props>(
1250
function warnForMissingKey(
1251
request: Request,
1252
key: null | string,
1259
- validated: number,
1253
componentDebugInfo: ReactComponentInfo,
1254
debugTask: null | ConsoleTask,
1255
): void {
1256
if (__DEV__) {
1264
- if (validated !== 2) {
1265
- return;
1266
- }
1267
-
1257
let didWarnForKey = request.didWarnForKey;
1258
if (didWarnForKey == null) {
1259
didWarnForKey = request.didWarnForKey = new WeakSet();
@@ -1573,6 +1562,21 @@ function renderElement(
1562
} else if (type === REACT_FRAGMENT_TYPE && key === null) {
1563
// For key-less fragments, we add a small optimization to avoid serializing
1564
// it as a wrapper.
1565
+ if (__DEV__ && enableOwnerStacks && validated === 2) {
1566
+ // Create a fake owner node for the error stack.
1567
+ const componentDebugInfo: ReactComponentInfo = {
1568
+ name: 'Fragment',
1569
+ env: (0, request.environmentName)(),
1570
+ owner: task.debugOwner,
1571
+ stack:
1572
+ task.debugStack === null
1573
+ ? null
1574
+ : filterStackTrace(request, task.debugStack, 1),
1575
+ debugStack: task.debugStack,
1576
+ debugTask: task.debugTask,
1577
+ };
1578
+ warnForMissingKey(request, key, componentDebugInfo, task.debugTask);
1579
+ }
1580
const prevImplicitSlot = task.implicitSlot;
1581
if (task.keyPath === null) {
1582
task.implicitSlot = true;
@@ -2921,7 +2925,7 @@ function emitErrorChunk(
2925
if (__DEV__) {
2926
let message;
2927
let stack: ReactStackTrace;
2924
- let env = request.environmentName();
2928
+ let env = (0, request.environmentName)();
2929
try {
2930
if (error instanceof Error) {
2931
// eslint-disable-next-line react-internal/safe-string-coercion
@@ -3442,7 +3446,7 @@ function emitConsoleChunk(
3446
}
3447
3448
// TODO: Don't double badge if this log came from another Flight Client.
3445
- const env = request.environmentName();
3449
+ const env = (0, request.environmentName)();
3450
const payload = [methodName, stackTrace, owner, env];
3451
// $FlowFixMe[method-unbinding]
3452
payload.push.apply(payload, args);
@@ -3611,7 +3615,7 @@ function retryTask(request: Request, task: Task): void {
3615
request.writtenObjects.set(resolvedModel, serializeByValueID(task.id));
3616
3617
if (__DEV__) {
3614
- const currentEnv = request.environmentName();
3618
+ const currentEnv = (0, request.environmentName)();
3619
if (currentEnv !== task.environmentName) {
3620
// The environment changed since we last emitted any debug information for this
3621
// task. We emit an entry that just includes the environment name change.
@@ -3629,7 +3633,7 @@ function retryTask(request: Request, task: Task): void {
3633
const json: string = stringify(resolvedModel);
3634
3635
if (__DEV__) {
3632
- const currentEnv = request.environmentName();
3636
+ const currentEnv = (0, request.environmentName)();
3637
if (currentEnv !== task.environmentName) {
3638
// The environment changed since we last emitted any debug information for this
3639
// task. We emit an entry that just includes the environment name change.