docs / core concepts
Flight Wire Format
The Flight wire format ruact speaks between Rails and React — what is on the wire and how to read it.
React Server Components use a streaming protocol called Flight to transfer the component tree from server to browser. ruact writes it from Ruby, and the browser reads it with React’s own Flight client (react-server-dom-webpack, which ships inside the gem). You do not need to understand Flight to use ruact — but knowing the basics helps when debugging unexpected output or adding support for new Ruby types.
Which client reads it
Section titled “Which client reads it”The browser decodes the page with React’s own Flight client, the client.browser build of react-server-dom-webpack. ruact does not ship a decoder of its own: it writes the subset of Flight that React’s client reads, and CI decodes payloads produced by the Ruby serializer with that client, in its development and production builds.
-
The copy in use. Version 19.3.0 ships inside the gem, so nothing is added to your
package.json. The npm package would also installwebpack; the vendored copy avoids that. -
Supported React:
^19.2.8. CI runs against 19.2.8 and 19.3.0. -
Using your own copy. Add
react-server-dom-webpackto your app’spackage.jsonand the Vite plugin uses it instead of the vendored one. A copy that is only installed as another package’s dependency is not used. Keep it on the same React minor as yourreact. -
Which one is running. Vite’s startup and build output print it, and so does
rails ruact:doctor:Flight client: react-server-dom-webpack 19.3.0 vendored in ruactWith your own copy, the doctor names its version and warns when it is built for another React minor than your
react.
The server and the browser runtime change together: a page served by one ruact version needs that version’s runtime. After a deploy that changes the wire, a tab still open on the old runtime fails on its next in-place navigation and has to reload. The changelog says when a release changes the wire.
What is Flight?
Section titled “What is Flight?”Flight is a text format made of rows, each encoding one piece of the React element tree. Most rows are one line:
1:I["/LikeButton.jsx",[],"LikeButton"]0:["$","$L1",null,{"postId":42}]- Row
0is always the root — the top-level React element tree for the page. - Row
1,2, … are supporting data: import registrations, async chunks, or referenced sub-trees.
Row types
Section titled “Row types”| Prefix | Meaning | Example |
|---|---|---|
| (none, just JSON) | Model row — a JSON value | 0:{"title":"Hello"} |
I |
Import row — registers a client module: [id, chunks, name] |
1:I["/Btn.jsx",[],"Button"] |
E |
Error row — encodes a serialized error | 2:E{"digest":"","name":"Error","message":"Suspense timeout exceeded","stack":[],"env":"Server"} |
T |
Text row — a string of 1024 bytes or more, framed by its byte length, with no newline after it | 3:T4b0,<1200 bytes of text> |
A model row points at a text row as "$3"; the browser puts the text back in its place. Because a text row can contain newlines and is not followed by one, a Flight payload cannot be read by splitting it into lines.
Row IDs are hexadecimal: 0, 1, 2, …, 9, a, b, …, f, 10, 11, …
How Ruby values map to Flight rows
Section titled “How Ruby values map to Flight rows”| Ruby value | Flight output |
|---|---|
nil |
0:null |
true / false |
0:true / 0:false |
42 |
0:42 |
2**53 (beyond ±(2^53 − 1)) |
0:"$n9007199254740992" → a BigInt in JavaScript |
Time, DateTime, ActiveSupport::TimeWithZone |
0:"$D2026-09-08T12:30:45.123Z" → a Date in JavaScript |
Date (a calendar day) |
0:"2026-09-08" → a string |
"hello" |
0:"hello" |
"$danger" |
0:"$$danger" ← $ prefix escaped to $$ |
| a string of 1024+ bytes | a T row, referenced as "$<id>" |
[1, "a", true] |
0:[1,"a",true] |
{ debug: true } |
0:{"debug":true} |
ClientReference |
Import row + model row referencing $L{id} |
<Suspense> in ERB |
a "$Sreact.suspense" symbol row as the element type; the deferred content is a lazy child, "children":"$L<id>" |
Client component reference example
Section titled “Client component reference example”When your ERB includes <LikeButton postId={@post.id} />, ruact emits:
1:I["/LikeButton.jsx",[],"LikeButton"]0:["$","$L1",null,{"postId":42}]Breaking it down:
-
Row 1 (import): registers
/LikeButton.jsxwith export name"LikeButton". The chunk list is empty: ruact’s runtime registers every client component when the page boots, so there is nothing to load -
Row 0 (root): a React element —
["$", type, key, props]:"$"— the React element marker"$L1"— the type: a lazy reference to import row1(the LikeButton module)null— the element key{"postId":42}— the props
In development, elements carry three more slots (
null,null,1): React’s development build reads the last one as “these children were checked”, so it does not warn about keys on ERB siblings and loops.
Why import rows come first
Section titled “Why import rows come first”The browser processes rows as they arrive. A model row that references "$L1" cannot be rendered until import row 1 is registered. ruact always emits import rows before the model rows that reference them.
Debugging Flight output
Section titled “Debugging Flight output”The simplest way to see the raw Flight output for any page:
curl -H "Accept: text/x-component" http://localhost:3000/posts/1The text/x-component Accept header tells ruact to return only the Flight payload (no HTML shell). This is what the browser sends during client-side navigation.
Adding support for a new Ruby type
Section titled “Adding support for a new Ruby type”If you need ruact to serialize a custom Ruby type, follow the fixture-first workflow described in CONTRIBUTING.md:
- Write a
.txtfixture with the expected wire output - Write a failing spec using
match_flight_fixture - Add a
case/whenbranch ingem/lib/ruact/flight/serializer.rb - Verify the spec passes with no regressions