@samitouri / QOS-React-1 / commits / 8366f3389d

[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 });