Skip to content

Commit 893316d

Browse files
committed
Don't dedupe Elements if they're in a non-default Context
If an element gets wrapped in a different server component then that has a different keyPath context and the element might end up with a different key. So we don't use the deduping mechanism if we're already inside a Server Component parent with a key or otherwise. Only the simple case gets deduped. The props of a client element are still deduped though if they're the same instance.
1 parent c49a32f commit 893316d

File tree

2 files changed

+103
-32
lines changed

2 files changed

+103
-32
lines changed

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 () => {

packages/react-server/src/ReactFlightServer.js

Lines changed: 60 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -788,8 +788,17 @@ function createTask(
788788
): Task {
789789
const id = request.nextChunkId++;
790790
if (typeof model === 'object' && model !== null) {
791-
// Register this model as having the ID we're about to write.
792-
request.writtenObjects.set(model, id);
791+
// If we're about to write this into a new task we can assign it an ID early so that
792+
// any other references can refer to the value we're about to write.
793+
if (
794+
enableServerComponentKeys &&
795+
(keyPath !== null || implicitSlot || context !== rootContextSnapshot)
796+
) {
797+
// If we're in some kind of context we can't necessarily reuse this object depending
798+
// what parent components are used.
799+
} else {
800+
request.writtenObjects.set(model, id);
801+
}
793802
}
794803
const task: Task = {
795804
id,
@@ -1251,18 +1260,31 @@ function renderModelDestructive(
12511260
const writtenObjects = request.writtenObjects;
12521261
const existingId = writtenObjects.get(value);
12531262
if (existingId !== undefined) {
1254-
if (existingId === -1) {
1255-
// Seen but not yet outlined.
1256-
const newId = outlineModel(request, value);
1257-
return serializeByValueID(newId);
1263+
if (
1264+
enableServerComponentKeys &&
1265+
(task.keyPath !== null ||
1266+
task.implicitSlot ||
1267+
task.context !== rootContextSnapshot)
1268+
) {
1269+
// If we're in some kind of context we can't reuse the result of this render or
1270+
// previous renders of this element. We only reuse elements if they're not wrapped
1271+
// by another Server Component.
12581272
} else if (modelRoot === value) {
12591273
// This is the ID we're currently emitting so we need to write it
12601274
// once but if we discover it again, we refer to it by id.
12611275
modelRoot = null;
1276+
} else if (existingId === -1) {
1277+
// Seen but not yet outlined.
1278+
// TODO: If we throw here we can treat this as suspending which causes an outline
1279+
// but that is able to reuse the same task if we're already in one but then that
1280+
// will be a lazy future value rather than guaranteed to exist but maybe that's good.
1281+
const newId = outlineModel(request, (value: any));
1282+
return serializeLazyID(newId);
12621283
} else {
1263-
// We've already emitted this as an outlined object, so we can
1264-
// just refer to that by its existing ID.
1265-
return serializeByValueID(existingId);
1284+
// We've already emitted this as an outlined object, so we can refer to that by its
1285+
// existing ID. We use a lazy reference since, unlike plain objects, elements might
1286+
// suspend so it might not have emitted yet even if we have the ID for it.
1287+
return serializeLazyID(existingId);
12661288
}
12671289
} else {
12681290
// This is the first time we've seen this object. We may never see it again
@@ -1317,7 +1339,18 @@ function renderModelDestructive(
13171339
// $FlowFixMe[method-unbinding]
13181340
if (typeof value.then === 'function') {
13191341
if (existingId !== undefined) {
1320-
if (modelRoot === value) {
1342+
if (
1343+
enableServerComponentKeys &&
1344+
(task.keyPath !== null ||
1345+
task.implicitSlot ||
1346+
task.context !== rootContextSnapshot)
1347+
) {
1348+
// If we're in some kind of context we can't reuse the result of this render or
1349+
// previous renders of this element. We only reuse Promises if they're not wrapped
1350+
// by another Server Component.
1351+
const promiseId = serializeThenable(request, task, (value: any));
1352+
return serializePromiseID(promiseId);
1353+
} else if (modelRoot === value) {
13211354
// This is the ID we're currently emitting so we need to write it
13221355
// once but if we discover it again, we refer to it by id.
13231356
modelRoot = null;
@@ -1357,14 +1390,14 @@ function renderModelDestructive(
13571390
}
13581391

13591392
if (existingId !== undefined) {
1360-
if (existingId === -1) {
1361-
// Seen but not yet outlined.
1362-
const newId = outlineModel(request, value);
1363-
return serializeByValueID(newId);
1364-
} else if (modelRoot === value) {
1393+
if (modelRoot === value) {
13651394
// This is the ID we're currently emitting so we need to write it
13661395
// once but if we discover it again, we refer to it by id.
13671396
modelRoot = null;
1397+
} else if (existingId === -1) {
1398+
// Seen but not yet outlined.
1399+
const newId = outlineModel(request, (value: any));
1400+
return serializeByValueID(newId);
13681401
} else {
13691402
// We've already emitted this as an outlined object, so we can
13701403
// just refer to that by its existing ID.
@@ -1768,15 +1801,18 @@ function retryTask(request: Request, task: Task): void {
17681801
task.keyPath = null;
17691802
task.implicitSlot = false;
17701803

1771-
// If the value is a string, it means it's a terminal value adn we already escaped it
1772-
// We don't need to escape it again so it's not passed the toJSON replacer.
1773-
// Object might contain unresolved values like additional elements.
1774-
// This is simulating what the JSON loop would do if this was part of it.
1775-
// $FlowFixMe[incompatible-type] stringify can return null
1776-
const json: string =
1777-
typeof resolvedModel === 'string'
1778-
? stringify(resolvedModel)
1779-
: stringify(resolvedModel, task.toJSON);
1804+
let json: string;
1805+
if (typeof resolvedModel === 'object' && resolvedModel !== null) {
1806+
// Object might contain unresolved values like additional elements.
1807+
// This is simulating what the JSON loop would do if this was part of it.
1808+
// $FlowFixMe[incompatible-type] stringify can return null for undefined but we never do
1809+
json = stringify(resolvedModel, task.toJSON);
1810+
} else {
1811+
// If the value is a string, it means it's a terminal value and we already escaped it
1812+
// We don't need to escape it again so it's not passed the toJSON replacer.
1813+
// $FlowFixMe[incompatible-type] stringify can return null for undefined but we never do
1814+
json = stringify(resolvedModel);
1815+
}
17801816
emitModelChunk(request, task.id, json);
17811817

17821818
request.abortableTasks.delete(task);

0 commit comments

Comments
 (0)