From 8ac4303604d9b85b30c71132a2a8f4f55f69ca8a Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Mon, 25 May 2026 19:45:47 -0500 Subject: [PATCH] [FlightReply] Don't drop FormData entries in `decodeReplyFromBusboy` (#36468) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes a regression from #36425 where referenced `FormData` entries can be dropped by `decodeReplyFromBusboy` when files are interleaved with text fields in the payload. `decodeReplyFromBusboy` queues text fields that arrive while a file is being streamed and flushes them after the last file's `'end'`, working around busboy emitting `'end'` deferred relative to subsequent `'field'` events. With multiple files interleaved with text, this loses the relative order of the affected text entries. The reorder was a long-standing but invisible issue — entries came back in the wrong order but were all present — until #36425 tightened how referenced FormData entries are collected from the backing store to rely on them being contiguous. With that assumption violated, referenced FormDatas can now come back with some entries dropped. The pattern is most easily surfaced through `useActionState` actions that return the submitted `FormData` as part of their state. This replaces the tail-flush with a linked list of pending files. Text fields that arrive while a file is in flight are queued on the tail file's `queuedFields`; fields that arrive when the list is empty resolve immediately. `flush()` walks from the head, resolving each completed file followed by its queued fields, and stops at the first file that hasn't ended yet. The backing FormData now matches the payload's order, restoring the contiguity assumption (and fixing the long-standing reorder as a side effect). The same change is applied to all five copies in `react-server-dom-{webpack,turbopack,parcel,esm,unbundled}`. Two new tests cover the multi-file interleave. fixes vercel/next.js#93822 --- .../src/client/DOMPropertyOperations.js | 9 +- .../src/server/ReactFizzConfigDOM.js | 12 +- .../__tests__/DOMPropertyOperations-test.js | 53 ++++++++ ...eactDOMServerIntegrationAttributes-test.js | 118 ++++++++++++++++++ 4 files changed, 188 insertions(+), 4 deletions(-) diff --git a/packages/react-dom-bindings/src/client/DOMPropertyOperations.js b/packages/react-dom-bindings/src/client/DOMPropertyOperations.js index df09445acc55..cd915b8d26cf 100644 --- a/packages/react-dom-bindings/src/client/DOMPropertyOperations.js +++ b/packages/react-dom-bindings/src/client/DOMPropertyOperations.js @@ -226,10 +226,13 @@ export function setValueForPropertyOnCustomComponent( } if (value === true) { - node.setAttribute(name, ''); - return; + const prefix = name.toLowerCase().slice(0, 5); + if (prefix !== 'data-' && prefix !== 'aria-') { + // for non aria and data attributes, set the attribute to an empty string (just the value on the DOM) + node.setAttribute(name, ''); + return; + } } - // From here, it's the same as any attribute setValueForAttribute(node, name, value); } diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index 691e49e563fd..e9c6f18a672d 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -4019,7 +4019,17 @@ function pushStartCustomElement( typeof propValue !== 'function' && typeof propValue !== 'symbol' ) { - if (propValue === false) { + const prefix = attributeName.toLowerCase().slice(0, 5); + const propValueNeedsStringification = + prefix === 'data-' || prefix === 'aria-'; + if ( + propValueNeedsStringification && + typeof propValue === 'boolean' + ) { + // aria and data attributes are stringified as 'true' if they have the boolean value `true` + // this is because data-foo="true" is the same as data-foo on the DOM + propValue = propValue ? 'true' : 'false'; + } else if (propValue === false) { continue; } else if (propValue === true) { propValue = ''; diff --git a/packages/react-dom/src/__tests__/DOMPropertyOperations-test.js b/packages/react-dom/src/__tests__/DOMPropertyOperations-test.js index 5bbc2c8dbac8..a40ba6c0a3fe 100644 --- a/packages/react-dom/src/__tests__/DOMPropertyOperations-test.js +++ b/packages/react-dom/src/__tests__/DOMPropertyOperations-test.js @@ -1151,6 +1151,59 @@ describe('DOMPropertyOperations', () => { expect(customElement.getAttribute('foo')).toBe(null); }); + it('aria attributes should have proper representation', async () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render( + , + ); + }); + const customElement = container.querySelector('my-custom-element'); + + expect(customElement.getAttribute('aria-disabled')).toBe('true'); + expect(customElement.getAttribute('aria-label')).toBe('label test'); + expect(customElement.getAttribute('aria-hidden')).toBe('false'); + expect(customElement.getAttribute('aria-required')).toBe(null); + expect(customElement.getAttribute('aria-colindex')).toBe('1'); + expect(customElement.getAttribute('aria-rowindex')).toBe('0'); + }); + + it('data attributes should have proper representation', async () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render( + , + ); + }); + const customElement = container.querySelector('my-custom-element'); + expect(customElement.getAttribute('data-false')).toBe('false'); + expect(customElement.getAttribute('data-true')).toBe('true'); + expect(customElement.getAttribute('data-undefined')).toBe(null); + expect(customElement.getAttribute('data-null')).toBe(null); + expect(customElement.getAttribute('data-label')).toBe('label test'); + expect(customElement.getAttribute('data-index')).toBe('1'); + expect(customElement.getAttribute('data-index-0')).toBe('0'); + }); + it('custom element custom event handlers assign multiple types', async () => { const container = document.createElement('div'); document.body.appendChild(container); diff --git a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationAttributes-test.js b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationAttributes-test.js index 9857718c35b7..18224136d5e7 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationAttributes-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationAttributes-test.js @@ -806,5 +806,123 @@ describe('ReactDOMServerIntegration', () => { expect(e.hasAttribute('foo')).toBe(false); }, ); + + describe('aria attributes', function () { + itRenders('simple strings on custom elements', async render => { + const e = await render(); + expect(e.getAttribute('aria-label')).toBe('hello'); + }); + + itRenders( + 'aria string prop with false value on custom elements', + async render => { + const e = await render(); + expect(e.getAttribute('aria-hidden')).toBe('false'); + }, + ); + + itRenders( + 'aria string prop with true value on custom elements', + async render => { + const e = await render(); + expect(e.getAttribute('aria-hidden')).toBe('true'); + }, + ); + + itRenders( + 'aria string prop with number value on custom elements', + async render => { + const e = await render(); + expect(e.getAttribute('aria-label')).toBe('1'); + }, + ); + + itRenders( + 'aria string prop with string value on custom elements', + async render => { + const e = await render(); + expect(e.getAttribute('aria-label')).toBe('hello'); + }, + ); + + itRenders( + 'no aria prop with null value on custom elements', + async render => { + const e = await render(); + expect(e.hasAttribute('aria-label')).toBe(false); + }, + ); + + itRenders( + 'no aria prop with undefined value on custom elements', + async render => { + const e = await render(); + expect(e.hasAttribute('aria-label')).toBe(false); + }, + ); + }); + }); + + describe('data attributes', function () { + itRenders('simple strings on custom elements', async render => { + const e = await render(); + expect(e.getAttribute('data-foo')).toBe('hello'); + }); + + itRenders( + 'data string prop with false value on custom elements', + async render => { + const e = await render(); + expect(e.getAttribute('data-foo')).toBe('false'); + }, + ); + + itRenders( + 'data string prop with true value on custom elements', + async render => { + const e = await render(); + expect(e.getAttribute('data-foo')).toBe('true'); + }, + ); + + itRenders( + 'no data prop with null value on custom elements', + async render => { + const e = await render(); + expect(e.hasAttribute('data-foo')).toBe(false); + }, + ); + + itRenders( + 'no data prop with undefined value on custom elements', + async render => { + const e = await render(); + expect(e.hasAttribute('data-foo')).toBe(false); + }, + ); + + itRenders( + 'no data prop with function value on custom elements', + async render => { + const e = await render(); + expect(e.hasAttribute('data-foo')).toBe(false); + }, + ); + + itRenders( + 'data string prop for number value on custom elements', + async render => { + const e = await render(); + expect(e.getAttribute('data-foo')).toBe('1'); + }, + ); + + itRenders( + 'data string prop for string value on custom elements', + async render => { + const e = await render(); + expect(e.getAttribute('data-foo')).toBe('hello'); + }, + ); }); });