Skip to content

Commit 3a90397

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 3a90397

File tree

2 files changed

+88
-22
lines changed

2 files changed

+88
-22
lines changed

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

+31
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,37 @@ describe('ReactFlightDOMEdge', () => {
225225
const stream = ReactServerDOMServer.renderToReadableStream(children);
226226
const [stream1, stream2] = passThrough(stream).tee();
227227

228+
const serializedContent = await readResult(stream1);
229+
230+
expect(serializedContent.length).toBeLessThan(400);
231+
expect(timesRendered).toBeLessThan(5);
232+
233+
const result = await ReactServerDOMClient.createFromReadableStream(
234+
stream2,
235+
{
236+
ssrManifest: {
237+
moduleMap: null,
238+
moduleLoading: null,
239+
},
240+
},
241+
);
242+
// Should still match the result when parsed
243+
expect(result).toEqual(resolvedChildren);
244+
});
245+
246+
it('should execute repeated host components only once', async () => {
247+
const div = <div>this is a long return value</div>;
248+
let timesRendered = 0;
249+
function ServerComponent() {
250+
timesRendered++;
251+
return div;
252+
}
253+
const element = <ServerComponent />;
254+
const children = new Array(30).fill(element);
255+
const resolvedChildren = new Array(30).fill(div);
256+
const stream = ReactServerDOMServer.renderToReadableStream(children);
257+
const [stream1, stream2] = passThrough(stream).tee();
258+
228259
const serializedContent = await readResult(stream1);
229260
expect(serializedContent.length).toBeLessThan(400);
230261
expect(timesRendered).toBeLessThan(5);

packages/react-server/src/ReactFlightServer.js

+57-22
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,30 @@ 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 {
12631284
// We've already emitted this as an outlined object, so we can
12641285
// just refer to that by its existing ID.
1265-
return serializeByValueID(existingId);
1286+
return serializeLazyID(existingId);
12661287
}
12671288
} else {
12681289
// This is the first time we've seen this object. We may never see it again
@@ -1317,7 +1338,18 @@ function renderModelDestructive(
13171338
// $FlowFixMe[method-unbinding]
13181339
if (typeof value.then === 'function') {
13191340
if (existingId !== undefined) {
1320-
if (modelRoot === value) {
1341+
if (
1342+
enableServerComponentKeys &&
1343+
(task.keyPath !== null ||
1344+
task.implicitSlot ||
1345+
task.context !== rootContextSnapshot)
1346+
) {
1347+
// If we're in some kind of context we can't reuse the result of this render or
1348+
// previous renders of this element. We only reuse Promises if they're not wrapped
1349+
// by another Server Component.
1350+
const promiseId = serializeThenable(request, task, (value: any));
1351+
return serializePromiseID(promiseId);
1352+
} else if (modelRoot === value) {
13211353
// This is the ID we're currently emitting so we need to write it
13221354
// once but if we discover it again, we refer to it by id.
13231355
modelRoot = null;
@@ -1357,14 +1389,14 @@ function renderModelDestructive(
13571389
}
13581390

13591391
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) {
1392+
if (modelRoot === value) {
13651393
// This is the ID we're currently emitting so we need to write it
13661394
// once but if we discover it again, we refer to it by id.
13671395
modelRoot = null;
1396+
} else if (existingId === -1) {
1397+
// Seen but not yet outlined.
1398+
const newId = outlineModel(request, (value: any));
1399+
return serializeByValueID(newId);
13681400
} else {
13691401
// We've already emitted this as an outlined object, so we can
13701402
// just refer to that by its existing ID.
@@ -1768,15 +1800,18 @@ function retryTask(request: Request, task: Task): void {
17681800
task.keyPath = null;
17691801
task.implicitSlot = false;
17701802

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);
1803+
let json: string;
1804+
if (typeof resolvedModel === 'object' && resolvedModel !== null) {
1805+
// Object might contain unresolved values like additional elements.
1806+
// This is simulating what the JSON loop would do if this was part of it.
1807+
// $FlowFixMe[incompatible-type] stringify can return null for undefined but we never do
1808+
json = stringify(resolvedModel, task.toJSON);
1809+
} else {
1810+
// If the value is a string, it means it's a terminal value and we already escaped it
1811+
// We don't need to escape it again so it's not passed the toJSON replacer.
1812+
// $FlowFixMe[incompatible-type] stringify can return null for undefined but we never do
1813+
json = stringify(resolvedModel);
1814+
}
17801815
emitModelChunk(request, task.id, json);
17811816

17821817
request.abortableTasks.delete(task);

0 commit comments

Comments
 (0)