Skip to content

Commit bc1e24b

Browse files
sebmarkbageAndyPengc12
authored andcommitted
[Flight] Support Keyed Server Components (facebook#28123)
Conceptually a Server Component in the tree is the same as a Client Component. When we render a Server Component with a key, that key should be used as part of the reconciliation process to ensure the children's state are preserved when they move in a set. The key of a child should also be used to clear the state of the children when that key changes. Conversely, if a Server Component doesn't have a key it should get an implicit key based on the slot number. It should not inherit the key of its children since the children don't know if that would collide with other keys in the set the Server Component is rendered in. A Client Component also has an identity based on the function's implementation type. That mainly has to do with the state (or future state after a refactor) that Component might contain. To transfer state between two implementations it needs to be of the same state type. This is not a concern for a Server Components since they never have state so identity doesn't matter. A Component returns a set of children. If it returns a single child, that's the same as returning a fragment of one child. So if you conditionally return a single child or a fragment, they should technically reconcile against each other. The simple way to do this is to simply emit a Fragment for every Server Component. That would be correct in all cases. Unfortunately that is also unfortunate since it bloats the payload in the common cases. It also means that Fiber creates an extra indirection in the runtime. Ideally we want to fold Server Component aways into zero cost on the client. At least where possible. The common cases are that you don't specify a key on a single return child, and that you do specify a key on a Server Component in a dynamic set. The approach in this PR treats a Server Component that returns other Server Components or Lazy Nodes as a sequence that can be folded away. I.e. the parts that don't generate any output in the RSC payload. Instead, it keeps track of their keys on an internal "context". Which gets reset after each new reified JSON node gets rendered. Then we transfer the accumulated keys from any parent Server Components onto the child element. In the simple case, the child just inherits the key of the parent. If the Server Component itself is keyless but a child isn't, we have to add a wrapper fragment to ensure that this fragment gets the implicit key but we can still use the key to reset state. This is unusual though because typically if you keyed something it's because it was already in a fragment. In the case a Server Component is keyed but forks its children using a fragment, we need to key that fragment so that the whole set can move around as one. In theory this could be flattened into a parent array but that gets tricky if something suspends, because then we can't send the siblings early. The main downside of this approach is that switching between single child and fragment in a Server Component isn't always going to reconcile against each other. That's because if we saw a single child first, we'd have to add the fragment preemptively in case it forks later. This semantic of React isn't very well known anyway and it might be ok to break it here for pragmatic reasons. The tests document this discrepancy. Another compromise of this approach is that when combining keys we don't escape them fully. We instead just use a simple `,` separated concat. This is probably good enough in practice. Additionally, since we don't encode the implicit 0 index slot key, you can move things around between parents which shouldn't really reconcile but does. This keeps the keys shorter and more human readable.
1 parent 14e1234 commit bc1e24b

10 files changed

+736
-56
lines changed

packages/react-client/src/__tests__/ReactFlight-test.js

Lines changed: 425 additions & 0 deletions
Large diffs are not rendered by default.

packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -226,20 +226,55 @@ describe('ReactFlightDOMEdge', () => {
226226
const [stream1, stream2] = passThrough(stream).tee();
227227

228228
const serializedContent = await readResult(stream1);
229+
229230
expect(serializedContent.length).toBeLessThan(400);
230231
expect(timesRendered).toBeLessThan(5);
231232

232-
const result = await ReactServerDOMClient.createFromReadableStream(
233-
stream2,
234-
{
235-
ssrManifest: {
236-
moduleMap: null,
237-
moduleLoading: null,
238-
},
233+
const model = await ReactServerDOMClient.createFromReadableStream(stream2, {
234+
ssrManifest: {
235+
moduleMap: null,
236+
moduleLoading: null,
239237
},
238+
});
239+
240+
// Use the SSR render to resolve any lazy elements
241+
const ssrStream = await ReactDOMServer.renderToReadableStream(model);
242+
// Should still match the result when parsed
243+
const result = await readResult(ssrStream);
244+
expect(result).toEqual(resolvedChildren.join('<!-- -->'));
245+
});
246+
247+
it('should execute repeated host components only once', async () => {
248+
const div = <div>this is a long return value</div>;
249+
let timesRendered = 0;
250+
function ServerComponent() {
251+
timesRendered++;
252+
return div;
253+
}
254+
const element = <ServerComponent />;
255+
const children = new Array(30).fill(element);
256+
const resolvedChildren = new Array(30).fill(
257+
'<div>this is a long return value</div>',
240258
);
259+
const stream = ReactServerDOMServer.renderToReadableStream(children);
260+
const [stream1, stream2] = passThrough(stream).tee();
261+
262+
const serializedContent = await readResult(stream1);
263+
expect(serializedContent.length).toBeLessThan(400);
264+
expect(timesRendered).toBeLessThan(5);
265+
266+
const model = await ReactServerDOMClient.createFromReadableStream(stream2, {
267+
ssrManifest: {
268+
moduleMap: null,
269+
moduleLoading: null,
270+
},
271+
});
272+
273+
// Use the SSR render to resolve any lazy elements
274+
const ssrStream = await ReactDOMServer.renderToReadableStream(model);
241275
// Should still match the result when parsed
242-
expect(result).toEqual(resolvedChildren);
276+
const result = await readResult(ssrStream);
277+
expect(result).toEqual(resolvedChildren.join(''));
243278
});
244279

245280
it('should execute repeated server components in a compact form', async () => {

0 commit comments

Comments
 (0)