[Flight] Transfer key validation of lazy nodes when they are unwrapped (#37258)
Hendrik Liebau committed
Aug 10, 2026 at 16:18 UTC
8366f3389d3717d718b447ebff0e6a785c7e2d4d
2 files changed
+326
-16
packages/react-client/src/ReactFlightClient.js
+38
-16
@@ -1280,10 +1280,11 @@ function getTaskName(type: mixed): string {
1280
type !== null &&
1281
type.$$typeof === REACT_LAZY_TYPE
1282
) {
1283
- if (type._init === readChunk) {
1284
- // This is a lazy node created by Flight. It is probably a client reference.
1285
- // We use the "use client" string to indicate that this is the boundary into
1286
- // the client. There will only be one for any given owner chain.
1283
+ if (type._payload instanceof ReactPromise) {
1284
+ // This is a lazy node created by Flight, i.e. it wraps a chunk. It is
1285
+ // probably a client reference. We use the "use client" string to indicate
1286
+ // that this is the boundary into the client. There will only be one for
1287
+ // any given owner chain.
1288
return '"use client"';
1289
}
1290
// We don't want to eagerly initialize the initializer in DEV mode so we can't
@@ -1374,16 +1375,6 @@ function initializeElement(
1375
}
1376
1377
if (lazyNode !== null) {
1377
- // In case the JSX runtime has validated the lazy type as a static child, we
1378
- // need to transfer this information to the element.
1379
- if (
1380
- lazyNode._store &&
1381
- lazyNode._store.validated &&
1382
- !element._store.validated
1383
- ) {
1384
- element._store.validated = lazyNode._store.validated;
1385
- }
1386
-
1378
// If the lazy node is initialized, we move its debug info to the inner
1379
// value.
1380
if (lazyNode._payload.status === INITIALIZED && lazyNode._debugInfo) {
@@ -1535,6 +1526,29 @@ function createElement(
1526
return element;
1527
}
1528
1529
+function transferValidation(store: {validated: 0 | 1 | 2}, value: mixed): void {
1530
+ if (store.validated && typeof value === 'object' && value !== null) {
1531
+ // Only elements and lazy nodes carry key validation. Any other value, e.g.
1532
+ // an array of children, needs to have its own items validated instead.
1533
+ const $$typeof = (value as any).$$typeof;
1534
+ if ($$typeof === REACT_ELEMENT_TYPE || $$typeof === REACT_LAZY_TYPE) {
1535
+ const valueStore = (value as any)._store;
1536
+ if (valueStore && !valueStore.validated) {
1537
+ valueStore.validated = store.validated;
1538
+ }
1539
+ }
1540
+ }
1541
+}
1542
+
1543
+function readChunkAndTransferValidation<T>(
1544
+ store: {validated: 0 | 1 | 2},
1545
+ payload: SomeChunk<T>,
1546
+): T {
1547
+ const value: T = readChunk(payload);
1548
+ transferValidation(store, value);
1549
+ return value;
1550
+}
1551
+
1552
function createLazyChunkWrapper<T>(
1553
chunk: SomeChunk<T>,
1554
validated: 0 | 1 | 2, // DEV-only
@@ -1547,8 +1561,16 @@ function createLazyChunkWrapper<T>(
1561
if (__DEV__) {
1562
// Forward the live array
1563
lazyType._debugInfo = chunk._debugInfo;
1550
- // Initialize a store for key validation by the JSX runtime.
1551
- lazyType._store = {validated: validated};
1564
+ // Initialize a store for key validation by the JSX runtime. It can only
1565
+ // validate the lazy node itself, because the value it refers to might not
1566
+ // exist yet at that point, e.g. if it's an outlined row that hasn't been
1567
+ // initialized. So the validation is transferred to the value when the lazy
1568
+ // node is unwrapped. If the value is another lazy node, unwrapping that one
1569
+ // forwards the validation further.
1570
+ const store = {validated: validated};
1571
+ lazyType._store = store;
1572
+ // $FlowFixMe[incompatible-type] `bind` loses the type argument.
1573
+ lazyType._init = readChunkAndTransferValidation.bind(null, store);
1574
}
1575
return lazyType;
1576
}
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js
+288
@@ -3137,4 +3137,292 @@ describe('ReactFlightDOMBrowser', () => {
3137
3138
expect(container.innerHTML).toBe('<div></div>');
3139
});
3140
+
3141
+ // Long enough to exceed MAX_ROW_SIZE in ReactFlightServer, which makes the
3142
+ // element prop that follows it be outlined into its own row.
3143
+ const longText = 'a'.repeat(4000);
3144
+
3145
+ it('should not have missing key warnings when a static child is outlined', async () => {
3146
+ const ClientComponent = clientExports(function ClientComponent({
3147
+ text,
3148
+ element,
3149
+ }) {
3150
+ return (
3151
+ <div>
3152
+ <span>{text.length}</span>
3153
+ {element}
3154
+ </div>
3155
+ );
3156
+ });
3157
+
3158
+ const stream = await serverAct(() =>
3159
+ ReactServerDOMServer.renderToReadableStream(
3160
+ <ClientComponent text={longText} element={<span>Hello</span>} />,
3161
+ webpackMap,
3162
+ ),
3163
+ );
3164
+
3165
+ function ClientRoot({response}) {
3166
+ return use(response);
3167
+ }
3168
+
3169
+ const response = ReactServerDOMClient.createFromReadableStream(stream);
3170
+
3171
+ const container = document.createElement('div');
3172
+ const root = ReactDOMClient.createRoot(container);
3173
+
3174
+ await act(() => {
3175
+ root.render(<ClientRoot response={response} />);
3176
+ });
3177
+
3178
+ expect(container.innerHTML).toBe(
3179
+ '<div><span>4000</span><span>Hello</span></div>',
3180
+ );
3181
+ });
3182
+
3183
+ it('should not have missing key warnings when an outlined static child is blocked on debug info', async () => {
3184
+ const ClientComponent = clientExports(function ClientComponent({
3185
+ text,
3186
+ element,
3187
+ }) {
3188
+ return (
3189
+ <div>
3190
+ <span>{text.length}</span>
3191
+ {element}
3192
+ </div>
3193
+ );
3194
+ });
3195
+
3196
+ let debugReadableStreamController;
3197
+
3198
+ const debugReadableStream = new ReadableStream({
3199
+ start(controller) {
3200
+ debugReadableStreamController = controller;
3201
+ },
3202
+ });
3203
+
3204
+ const stream = await serverAct(() =>
3205
+ ReactServerDOMServer.renderToReadableStream(
3206
+ <ClientComponent text={longText} element={<span>Hello</span>} />,
3207
+ webpackMap,
3208
+ {
3209
+ debugChannel: {
3210
+ writable: new WritableStream({
3211
+ write(chunk) {
3212
+ debugReadableStreamController.enqueue(chunk);
3213
+ },
3214
+ close() {
3215
+ debugReadableStreamController.close();
3216
+ },
3217
+ }),
3218
+ },
3219
+ },
3220
+ ),
3221
+ );
3222
+
3223
+ function ClientRoot({response}) {
3224
+ return use(response);
3225
+ }
3226
+
3227
+ const response = ReactServerDOMClient.createFromReadableStream(stream, {
3228
+ debugChannel: {readable: createDelayedStream(debugReadableStream)},
3229
+ });
3230
+
3231
+ const container = document.createElement('div');
3232
+ const root = ReactDOMClient.createRoot(container);
3233
+
3234
+ await act(() => {
3235
+ root.render(<ClientRoot response={response} />);
3236
+ });
3237
+
3238
+ // Wait for the debug info to be processed.
3239
+ await act(() => {});
3240
+
3241
+ expect(container.innerHTML).toBe(
3242
+ '<div><span>4000</span><span>Hello</span></div>',
3243
+ );
3244
+ });
3245
+
3246
+ it('should have missing key warnings when an outlined element is used in an array', async () => {
3247
+ const ClientComponent = clientExports(function ClientComponent({
3248
+ text,
3249
+ element,
3250
+ }) {
3251
+ return (
3252
+ <div>
3253
+ <span>{text.length}</span>
3254
+ {[element]}
3255
+ </div>
3256
+ );
3257
+ });
3258
+
3259
+ const stream = await serverAct(() =>
3260
+ ReactServerDOMServer.renderToReadableStream(
3261
+ <ClientComponent text={longText} element={<span>Hello</span>} />,
3262
+ webpackMap,
3263
+ ),
3264
+ );
3265
+
3266
+ function ClientRoot({response}) {
3267
+ return use(response);
3268
+ }
3269
+
3270
+ const response = ReactServerDOMClient.createFromReadableStream(stream);
3271
+
3272
+ const container = document.createElement('div');
3273
+ const root = ReactDOMClient.createRoot(container);
3274
+
3275
+ await act(() => {
3276
+ root.render(<ClientRoot response={response} />);
3277
+ });
3278
+
3279
+ assertConsoleErrorDev([
3280
+ 'Each child in a list should have a unique "key" prop.\n\n' +
3281
+ 'Check the render method of `div`. ' +
3282
+ 'See https://react.dev/link/warning-keys for more information.\n' +
3283
+ ' in span (at **)',
3284
+ ]);
3285
+
3286
+ expect(container.innerHTML).toBe(
3287
+ '<div><span>4000</span><span>Hello</span></div>',
3288
+ );
3289
+ });
3290
+
3291
+ it('should have missing key warnings when an outlined element that is blocked on debug info is used in an array', async () => {
3292
+ const ClientComponent = clientExports(function ClientComponent({
3293
+ text,
3294
+ element,
3295
+ }) {
3296
+ return (
3297
+ <div>
3298
+ <span>{text.length}</span>
3299
+ {[element]}
3300
+ </div>
3301
+ );
3302
+ });
3303
+
3304
+ let debugReadableStreamController;
3305
+
3306
+ const debugReadableStream = new ReadableStream({
3307
+ start(controller) {
3308
+ debugReadableStreamController = controller;
3309
+ },
3310
+ });
3311
+
3312
+ const stream = await serverAct(() =>
3313
+ ReactServerDOMServer.renderToReadableStream(
3314
+ <ClientComponent text={longText} element={<span>Hello</span>} />,
3315
+ webpackMap,
3316
+ {
3317
+ debugChannel: {
3318
+ writable: new WritableStream({
3319
+ write(chunk) {
3320
+ debugReadableStreamController.enqueue(chunk);
3321
+ },
3322
+ close() {
3323
+ debugReadableStreamController.close();
3324
+ },
3325
+ }),
3326
+ },
3327
+ },
3328
+ ),
3329
+ );
3330
+
3331
+ function ClientRoot({response}) {
3332
+ return use(response);
3333
+ }
3334
+
3335
+ const response = ReactServerDOMClient.createFromReadableStream(stream, {
3336
+ debugChannel: {readable: createDelayedStream(debugReadableStream)},
3337
+ });
3338
+
3339
+ const container = document.createElement('div');
3340
+ const root = ReactDOMClient.createRoot(container);
3341
+
3342
+ await act(() => {
3343
+ root.render(<ClientRoot response={response} />);
3344
+ });
3345
+
3346
+ // The element can only be rendered, and therefore validated, after it's
3347
+ // unblocked by the debug info.
3348
+ await act(() => {});
3349
+
3350
+ assertConsoleErrorDev([
3351
+ 'Each child in a list should have a unique "key" prop.\n\n' +
3352
+ 'Check the render method of `div`. ' +
3353
+ 'See https://react.dev/link/warning-keys for more information.\n' +
3354
+ ' in span (at **)',
3355
+ ]);
3356
+
3357
+ expect(container.innerHTML).toBe(
3358
+ '<div><span>4000</span><span>Hello</span></div>',
3359
+ );
3360
+ });
3361
+
3362
+ describe('with console.createTask', () => {
3363
+ // Stands in for what a browser console does with fake tasks: whatever runs
3364
+ // inside a task is shown under that task's name in the async stack. This is
3365
+ // the same setup that `ReactServer-test` uses to assert on task names.
3366
+ let currentTask;
3367
+
3368
+ beforeEach(() => {
3369
+ const {AsyncLocalStorage} = require('node:async_hooks');
3370
+ currentTask = new AsyncLocalStorage();
3371
+ (console: any).createTask = taskName => ({
3372
+ run: taskFn => {
3373
+ const parentTask = currentTask.getStore() || '';
3374
+ return currentTask.run(parentTask + '\n' + taskName, taskFn);
3375
+ },
3376
+ });
3377
+
3378
+ // `supportsCreateTask` is captured when ReactFlightClient is required, so
3379
+ // the client modules need to be required again with this in place.
3380
+ jest.resetModules();
3381
+ patchMessageChannel();
3382
+ ({act} = require('internal-test-utils'));
3383
+ React = require('react');
3384
+ use = React.use;
3385
+ ReactDOMClient = require('react-dom/client');
3386
+ ReactServerDOMClient = require('react-server-dom-webpack/client');
3387
+ });
3388
+
3389
+ afterEach(() => {
3390
+ delete (console: any).createTask;
3391
+ });
3392
+
3393
+ // @gate __DEV__
3394
+ it('renders a client component inside a "use client" task', async () => {
3395
+ let taskWhileRendering;
3396
+
3397
+ const ClientComponent = clientExports(function ClientComponent() {
3398
+ taskWhileRendering = currentTask.getStore();
3399
+ return <span>Hello</span>;
3400
+ });
3401
+
3402
+ const stream = await serverAct(() =>
3403
+ ReactServerDOMServer.renderToReadableStream(
3404
+ <ClientComponent />,
3405
+ webpackMap,
3406
+ ),
3407
+ );
3408
+
3409
+ function ClientRoot({response}) {
3410
+ return use(response);
3411
+ }
3412
+
3413
+ const response = ReactServerDOMClient.createFromReadableStream(stream);
3414
+
3415
+ const container = document.createElement('div');
3416
+ const root = ReactDOMClient.createRoot(container);
3417
+
3418
+ await act(() => {
3419
+ root.render(<ClientRoot response={response} />);
3420
+ });
3421
+
3422
+ expect(container.innerHTML).toBe('<span>Hello</span>');
3423
+ // The element's type is a lazy node wrapping the client reference, so the
3424
+ // task that the component renders in marks the boundary into the client.
3425
+ expect(taskWhileRendering).toBe('\n"use client"');
3426
+ });
3427
+ });
3428
});