Skip to content

[Flight Reply] Decode FormData part ids as hex - #37879

Open
zhoubiao188 wants to merge 1 commit into
react:mainfrom
zhoubiao188:fix-flight-reply-formdata-id-radix
Open

zhoubiao188 wants to merge 1 commit into
react:mainfrom
zhoubiao188:fix-flight-reply-formdata-id-radix

Conversation

@zhoubiao188

Copy link
Copy Markdown
Contributor

Summary

A FormData passed to a Server Action is deserialized as an empty FormData whenever its part id is 10 or higher, silently dropping every field.

The wire format is $K<id> with id in hex, like every other reference in the protocol. But the backing entries are written by the client under <formFieldPrefix>_<refId>_<key>, where refId is interpolated as a decimal number:

// react-client/src/ReactFlightReplyClient.js
const refId = nextPartId++;
const prefix = formFieldPrefix + '_' + refId + '_';
value.forEach((originalValue, originalKey) => {
  data.append(prefix + originalKey, originalValue);
});
return serializeFormDataReference(refId); // '$K' + refId.toString(16)

parseModelString then used the raw hex substring as the key prefix instead of converting it back:

const stringId = value.slice(2);
const formPrefix = response._prefix + '_' + stringId + '_';

The two representations only agree below 10, so for any id >= 10 the lookup matches nothing. consumeBackingEntries returns an empty FormData and, because it doesn't advance the cursor, the entries are left behind for the rest of the reply.

Every other id-carrying branch in the same function already decodes hex before building a backing key — parseTypedArray (parseInt(reference.slice(2), 16)), 'B' (parseInt(value.slice(2), 16)), getOutlinedModel (parseInt(path[0], 16)) and getChunk (takes a number). 'K' was the only exception.

This is reachable from encodeReply whenever ten or more parts are outlined before the FormData (promises, Blobs, typed arrays, iterators, other FormDatas...), and from bound Server Actions whose bound arguments include one.

Test

Added a case to ReactFlightDOMReply-test.js that outlines nine promises first, so the FormData becomes part 10 — the first id whose hex form differs from its decimal form. Failing output before the fix:

● ReactFlightDOMReply › can pass FormData after at least ten outlined parts

  expect(received).toEqual(expected) // deep equality

  - Expected  - 6
  + Received  + 1

  - Array [
  -   Array [
  -     "hello",
  -     "world",
  -   ],
  - ]
  + Array []

Verification

  • ReactFlightDOMReply (webpack / Node / Edge), ReactFlightDOMForm, ReactFlightTurbopackDOMReply, ReactFlightDOMNode — all pass on experimental, plus -r=stable and --prod for the reply suites.
  • yarn linc and prettier check-changed clean.
  • No overlap with [Flight Reply] Check that a typed array's backing entry is a Blob #37822, which touches parseTypedArray further down the same file and adds its tests earlier in the same describe block.

`$K<id>` encodes the part id in hex like every other reference, but
`parseModelString` passed the raw substring straight through as the prefix
for the backing entries. The client writes those entries as
`<formFieldPrefix>_<refId>_<key>`, where `refId` is the decimal number, so
the two representations only agree for ids below 10.

For any part id of 10 or higher the prefix doesn't match, the cursor never
advances past the block, and the referenced FormData deserializes as an
empty FormData -- silently dropping every field. That's reachable from
`encodeReply` whenever ten or more parts are outlined before the FormData
(promises, blobs, typed arrays, other FormData, ...), and from bound Server
Actions whose bound arguments include one.

Decode the id the same way the Blob, typed array and stream branches in
this file already do.
@meta-cla meta-cla Bot added the CLA Signed label Oct 17, 2026
@github-actions

github-actions Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

Comparing: 098d2e8...66d9f2b

Critical size changes

Includes critical production bundles, as well as any change greater than 2%:

Name +/- Base Current +/- gzip Base gzip Current gzip
oss-stable/react-dom/cjs/react-dom.production.js = 7.35 kB 7.35 kB = 1.96 kB 1.96 kB
oss-stable/react-dom/cjs/react-dom-client.production.js = 626.10 kB 626.10 kB = 110.63 kB 110.63 kB
oss-experimental/react-dom/cjs/react-dom.production.js = 7.36 kB 7.36 kB -0.05% 1.96 kB 1.96 kB
oss-experimental/react-dom/cjs/react-dom-client.production.js = 697.31 kB 697.31 kB = 122.07 kB 122.07 kB
facebook-www/ReactDOM-prod.classic.js = 715.35 kB 715.35 kB = 125.14 kB 125.14 kB
facebook-www/ReactDOM-prod.modern.js = 705.66 kB 705.66 kB = 123.57 kB 123.57 kB

Significant size changes

Includes any change greater than 0.2%:

(No significant changes)

Generated by sizebot against 66d9f2b

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant