diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js
index fc351132a3135..8e038bfc0f805 100644
--- a/packages/react-client/src/__tests__/ReactFlight-test.js
+++ b/packages/react-client/src/__tests__/ReactFlight-test.js
@@ -1700,4 +1700,429 @@ describe('ReactFlight', () => {
expect(errors).toEqual([]);
});
+
+ // @gate enableServerComponentKeys
+ it('preserves state when keying a server component', async () => {
+ function StatefulClient({name}) {
+ const [state] = React.useState(name.toLowerCase());
+ return state;
+ }
+ const Stateful = clientReference(StatefulClient);
+
+ function Item({item}) {
+ return (
+
+ {item}
+
+
+ );
+ }
+
+ function Items({items}) {
+ return items.map(item => {
+ return ;
+ });
+ }
+
+ const transport = ReactNoopFlightServer.render(
+ ,
+ );
+
+ await act(async () => {
+ ReactNoop.render(await ReactNoopFlightClient.read(transport));
+ });
+
+ expect(ReactNoop).toMatchRenderedOutput(
+ <>
+ Aa
+ Bb
+ Cc
+ >,
+ );
+
+ const transport2 = ReactNoopFlightServer.render(
+ ,
+ );
+
+ await act(async () => {
+ ReactNoop.render(await ReactNoopFlightClient.read(transport2));
+ });
+
+ expect(ReactNoop).toMatchRenderedOutput(
+ <>
+ Bb
+ Aa
+ Dd
+ Cc
+ >,
+ );
+ });
+
+ // @gate enableServerComponentKeys
+ it('does not inherit keys of children inside a server component', async () => {
+ function StatefulClient({name, initial}) {
+ const [state] = React.useState(initial);
+ return state;
+ }
+ const Stateful = clientReference(StatefulClient);
+
+ function Item({item, initial}) {
+ // This key is the key of the single item of this component.
+ // It's NOT part of the key of the list the parent component is
+ // in.
+ return (
+
+ {item}
+
+
+ );
+ }
+
+ function IndirectItem({item, initial}) {
+ // Even though we render two items with the same child key this key
+ // should not conflict, because the key belongs to the parent slot.
+ return ;
+ }
+
+ // These items don't have their own keys because they're in a fixed set
+ const transport = ReactNoopFlightServer.render(
+ <>
+
+
+
+
+ >,
+ );
+
+ await act(async () => {
+ ReactNoop.render(await ReactNoopFlightClient.read(transport));
+ });
+
+ expect(ReactNoop).toMatchRenderedOutput(
+ <>
+ A1
+ B2
+ C5
+ C6
+ >,
+ );
+
+ // This means that they shouldn't swap state when the properties update
+ const transport2 = ReactNoopFlightServer.render(
+ <>
+
+
+
+
+ >,
+ );
+
+ await act(async () => {
+ ReactNoop.render(await ReactNoopFlightClient.read(transport2));
+ });
+
+ expect(ReactNoop).toMatchRenderedOutput(
+ <>
+ B3
+ A4
+ C5
+ C6
+ >,
+ );
+ });
+
+ // @gate enableServerComponentKeys
+ it('shares state between single return and array return in a parent', async () => {
+ function StatefulClient({name, initial}) {
+ const [state] = React.useState(initial);
+ return state;
+ }
+ const Stateful = clientReference(StatefulClient);
+
+ function Item({item, initial}) {
+ // This key is the key of the single item of this component.
+ // It's NOT part of the key of the list the parent component is
+ // in.
+ return (
+
+ {item}
+
+
+ );
+ }
+
+ function Condition({condition}) {
+ if (condition) {
+ return ;
+ }
+ // The first item in the fragment is the same as the single item.
+ return (
+ <>
+
+
+ >
+ );
+ }
+
+ function ConditionPlain({condition}) {
+ if (condition) {
+ return (
+
+ C
+
+
+ );
+ }
+ // The first item in the fragment is the same as the single item.
+ return (
+ <>
+
+ C
+
+
+
+ D
+
+
+ >
+ );
+ }
+
+ const transport = ReactNoopFlightServer.render(
+ // This two item wrapper ensures we're already one step inside an array.
+ // A single item is not the same as a set when it's nested one level.
+ <>
+
+
+
+
+
+
+
+
+
+ >,
+ );
+
+ await act(async () => {
+ ReactNoop.render(await ReactNoopFlightClient.read(transport));
+ });
+
+ expect(ReactNoop).toMatchRenderedOutput(
+ <>
+
+ A1
+
+
+ C1
+
+
+ C1
+
+ >,
+ );
+
+ const transport2 = ReactNoopFlightServer.render(
+ <>
+
+
+
+
+
+
+ {null}
+
+
+
+ >,
+ );
+
+ await act(async () => {
+ ReactNoop.render(await ReactNoopFlightClient.read(transport2));
+ });
+
+ // We're intentionally breaking from the semantics here for efficiency of the protocol.
+ // In the case a Server Component inside a fragment is itself implicitly keyed but its
+ // return value has a key, then we need a wrapper fragment. This means they can't
+ // reconcile. To solve this we would need to add a wrapper fragment to every Server
+ // Component just in case it returns a fragment later which is a lot.
+ expect(ReactNoop).toMatchRenderedOutput(
+ <>
+
+ A2{/* This should be A1 ideally */}
+ B3
+
+
+ C1
+ D3
+
+
+ C1
+ D3
+
+ >,
+ );
+ });
+
+ it('shares state between single return and array return in a set', async () => {
+ function StatefulClient({name, initial}) {
+ const [state] = React.useState(initial);
+ return state;
+ }
+ const Stateful = clientReference(StatefulClient);
+
+ function Item({item, initial}) {
+ // This key is the key of the single item of this component.
+ // It's NOT part of the key of the list the parent component is
+ // in.
+ return (
+
+ {item}
+
+
+ );
+ }
+
+ function Condition({condition}) {
+ if (condition) {
+ return ;
+ }
+ // The first item in the fragment is the same as the single item.
+ return (
+ <>
+
+
+ >
+ );
+ }
+
+ function ConditionPlain({condition}) {
+ if (condition) {
+ return (
+
+ C
+
+
+ );
+ }
+ // The first item in the fragment is the same as the single item.
+ return (
+ <>
+
+ C
+
+
+
+ D
+
+
+ >
+ );
+ }
+
+ const transport = ReactNoopFlightServer.render(
+ // This two item wrapper ensures we're already one step inside an array.
+ // A single item is not the same as a set when it's nested one level.
+
+
+
+
+
,
+ );
+
+ await act(async () => {
+ ReactNoop.render(await ReactNoopFlightClient.read(transport));
+ });
+
+ expect(ReactNoop).toMatchRenderedOutput(
+
+ A1
+ C1
+ C1
+
,
+ );
+
+ const transport2 = ReactNoopFlightServer.render(
+
+
+
+ {null}
+
+
,
+ );
+
+ await act(async () => {
+ ReactNoop.render(await ReactNoopFlightClient.read(transport2));
+ });
+
+ // We're intentionally breaking from the semantics here for efficiency of the protocol.
+ // The issue with this test scenario is that when the Server Component is in a set,
+ // the next slot can't be conditionally a fragment or single. That would require wrapping
+ // in an additional fragment for every single child just in case it every expands to a
+ // fragment.
+ expect(ReactNoop).toMatchRenderedOutput(
+
+ A2{/* Should be A1 */}
+ B3
+ C2{/* Should be C1 */}
+ D3
+ C2{/* Should be C1 */}
+ D3
+
,
+ );
+ });
+
+ // @gate enableServerComponentKeys
+ it('preserves state with keys split across async work', async () => {
+ let resolve;
+ const promise = new Promise(r => (resolve = r));
+
+ function StatefulClient({name}) {
+ const [state] = React.useState(name.toLowerCase());
+ return state;
+ }
+ const Stateful = clientReference(StatefulClient);
+
+ function Item({name}) {
+ if (name === 'A') {
+ return promise.then(() => (
+
+ {name}
+
+
+ ));
+ }
+ return (
+
+ {name}
+
+
+ );
+ }
+
+ const transport = ReactNoopFlightServer.render([
+ ,
+ null,
+ ]);
+
+ // Create a gap in the stream
+ await resolve();
+
+ await act(async () => {
+ ReactNoop.render(await ReactNoopFlightClient.read(transport));
+ });
+
+ expect(ReactNoop).toMatchRenderedOutput(Aa
);
+
+ const transport2 = ReactNoopFlightServer.render([
+ null,
+ ,
+ ]);
+
+ await act(async () => {
+ ReactNoop.render(await ReactNoopFlightClient.read(transport2));
+ });
+
+ expect(ReactNoop).toMatchRenderedOutput(Ba
);
+ });
});
diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js
index 5d966a16ded59..7c9271fcdcc19 100644
--- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js
+++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js
@@ -226,20 +226,55 @@ describe('ReactFlightDOMEdge', () => {
const [stream1, stream2] = passThrough(stream).tee();
const serializedContent = await readResult(stream1);
+
expect(serializedContent.length).toBeLessThan(400);
expect(timesRendered).toBeLessThan(5);
- const result = await ReactServerDOMClient.createFromReadableStream(
- stream2,
- {
- ssrManifest: {
- moduleMap: null,
- moduleLoading: null,
- },
+ const model = await ReactServerDOMClient.createFromReadableStream(stream2, {
+ ssrManifest: {
+ moduleMap: null,
+ moduleLoading: null,
},
+ });
+
+ // Use the SSR render to resolve any lazy elements
+ const ssrStream = await ReactDOMServer.renderToReadableStream(model);
+ // Should still match the result when parsed
+ const result = await readResult(ssrStream);
+ expect(result).toEqual(resolvedChildren.join(''));
+ });
+
+ it('should execute repeated host components only once', async () => {
+ const div = this is a long return value
;
+ let timesRendered = 0;
+ function ServerComponent() {
+ timesRendered++;
+ return div;
+ }
+ const element = ;
+ const children = new Array(30).fill(element);
+ const resolvedChildren = new Array(30).fill(
+ 'this is a long return value
',
);
+ const stream = ReactServerDOMServer.renderToReadableStream(children);
+ const [stream1, stream2] = passThrough(stream).tee();
+
+ const serializedContent = await readResult(stream1);
+ expect(serializedContent.length).toBeLessThan(400);
+ expect(timesRendered).toBeLessThan(5);
+
+ const model = await ReactServerDOMClient.createFromReadableStream(stream2, {
+ ssrManifest: {
+ moduleMap: null,
+ moduleLoading: null,
+ },
+ });
+
+ // Use the SSR render to resolve any lazy elements
+ const ssrStream = await ReactDOMServer.renderToReadableStream(model);
// Should still match the result when parsed
- expect(result).toEqual(resolvedChildren);
+ const result = await readResult(ssrStream);
+ expect(result).toEqual(resolvedChildren.join(''));
});
it('should execute repeated server components in a compact form', async () => {
diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js
index 683cbb9feb6f7..f04642737e167 100644
--- a/packages/react-server/src/ReactFlightServer.js
+++ b/packages/react-server/src/ReactFlightServer.js
@@ -16,6 +16,7 @@ import {
enablePostpone,
enableTaint,
enableServerContext,
+ enableServerComponentKeys,
} from 'shared/ReactFeatureFlags';
import {
@@ -181,6 +182,8 @@ type Task = {
model: ReactClientValue,
ping: () => void,
toJSON: (key: string, value: ReactClientValue) => ReactJSONValue,
+ keyPath: null | string, // parent server component keys
+ implicitSlot: boolean, // true if the root server component of this sequence had a null key
context: ContextSnapshot,
thenableState: ThenableState | null,
};
@@ -314,7 +317,14 @@ export function createRequest(
};
request.pendingChunks++;
const rootContext = createRootContext(context);
- const rootTask = createTask(request, model, rootContext, abortSet);
+ const rootTask = createTask(
+ request,
+ model,
+ null,
+ false,
+ rootContext,
+ abortSet,
+ );
pingedTasks.push(rootTask);
return request;
}
@@ -338,12 +348,18 @@ function createRootContext(
const POP = {};
-function serializeThenable(request: Request, thenable: Thenable): number {
+function serializeThenable(
+ request: Request,
+ task: Task,
+ thenable: Thenable,
+): number {
request.pendingChunks++;
const newTask = createTask(
request,
null,
- getActiveContext(),
+ task.keyPath, // the server component sequence continues through Promise-as-a-child.
+ task.implicitSlot,
+ task.context,
request.abortableTasks,
);
@@ -500,11 +516,86 @@ function createLazyWrapperAroundWakeable(wakeable: Wakeable) {
return lazyType;
}
+function renderFragment(
+ request: Request,
+ task: Task,
+ children: $ReadOnlyArray,
+): ReactJSONValue {
+ if (!enableServerComponentKeys) {
+ return children;
+ }
+ if (task.keyPath !== null) {
+ // We have a Server Component that specifies a key but we're now splitting
+ // the tree using a fragment.
+ const fragment = [
+ REACT_ELEMENT_TYPE,
+ REACT_FRAGMENT_TYPE,
+ task.keyPath,
+ {children},
+ ];
+ if (!task.implicitSlot) {
+ // If this was keyed inside a set. I.e. the outer Server Component was keyed
+ // then we need to handle reorders of the whole set. To do this we need to wrap
+ // this array in a keyed Fragment.
+ return fragment;
+ }
+ // If the outer Server Component was implicit but then an inner one had a key
+ // we don't actually need to be able to move the whole set around. It'll always be
+ // in an implicit slot. The key only exists to be able to reset the state of the
+ // children. We could achieve the same effect by passing on the keyPath to the next
+ // set of components inside the fragment. This would also allow a keyless fragment
+ // reconcile against a single child.
+ // Unfortunately because of JSON.stringify, we can't call the recursive loop for
+ // each child within this context because we can't return a set with already resolved
+ // values. E.g. a string would get double encoded. Returning would pop the context.
+ // So instead, we wrap it with an unkeyed fragment and inner keyed fragment.
+ return [fragment];
+ }
+ // Since we're yielding here, that implicitly resets the keyPath context on the
+ // way up. Which is what we want since we've consumed it. If this changes to
+ // be recursive serialization, we need to reset the keyPath and implicitSlot,
+ // before recursing here.
+ return children;
+}
+
+function renderClientElement(
+ task: Task,
+ type: any,
+ key: null | string,
+ props: any,
+): ReactJSONValue {
+ if (!enableServerComponentKeys) {
+ return [REACT_ELEMENT_TYPE, type, key, props];
+ }
+ // We prepend the terminal client element that actually gets serialized with
+ // the keys of any Server Components which are not serialized.
+ const keyPath = task.keyPath;
+ if (key === null) {
+ key = keyPath;
+ } else if (keyPath !== null) {
+ key = keyPath + ',' + key;
+ }
+ const element = [REACT_ELEMENT_TYPE, type, key, props];
+ if (task.implicitSlot && key !== null) {
+ // The root Server Component had no key so it was in an implicit slot.
+ // If we had a key lower, it would end up in that slot with an explicit key.
+ // We wrap the element in a fragment to give it an implicit key slot with
+ // an inner explicit key.
+ return [element];
+ }
+ // Since we're yielding here, that implicitly resets the keyPath context on the
+ // way up. Which is what we want since we've consumed it. If this changes to
+ // be recursive serialization, we need to reset the keyPath and implicitSlot,
+ // before recursing here. We also need to reset it once we render into an array
+ // or anything else too which we also get implicitly.
+ return element;
+}
+
function renderElement(
request: Request,
task: Task,
type: any,
- key: null | React$Key,
+ key: null | string,
ref: mixed,
props: any,
): ReactJSONValue {
@@ -525,7 +616,7 @@ function renderElement(
if (typeof type === 'function') {
if (isClientReference(type)) {
// This is a reference to a Client Component.
- return [REACT_ELEMENT_TYPE, type, key, props];
+ return renderClientElement(task, type, key, props);
}
// This is a server-side component.
@@ -552,31 +643,52 @@ function renderElement(
// the thenable here.
result = createLazyWrapperAroundWakeable(result);
}
- return renderModelDestructive(request, task, emptyRoot, '', result);
+ // Track this element's key on the Server Component on the keyPath context..
+ const prevKeyPath = task.keyPath;
+ const prevImplicitSlot = task.implicitSlot;
+ if (key !== null) {
+ // Append the key to the path. Technically a null key should really add the child
+ // index. We don't do that to hold the payload small and implementation simple.
+ task.keyPath = prevKeyPath === null ? key : prevKeyPath + ',' + key;
+ } else if (prevKeyPath === null) {
+ // This sequence of Server Components has no keys. This means that it was rendered
+ // in a slot that needs to assign an implicit key. Even if children below have
+ // explicit keys, they should not be used for the outer most key since it might
+ // collide with other slots in that set.
+ task.implicitSlot = true;
+ }
+ const json = renderModelDestructive(request, task, emptyRoot, '', result);
+ task.keyPath = prevKeyPath;
+ task.implicitSlot = prevImplicitSlot;
+ return json;
} else if (typeof type === 'string') {
// This is a host element. E.g. HTML.
- return [REACT_ELEMENT_TYPE, type, key, props];
+ return renderClientElement(task, type, key, props);
} else if (typeof type === 'symbol') {
- if (type === REACT_FRAGMENT_TYPE) {
+ if (type === REACT_FRAGMENT_TYPE && key === null) {
// For key-less fragments, we add a small optimization to avoid serializing
// it as a wrapper.
- // TODO: If a key is specified, we should propagate its key to any children.
- // Same as if a Server Component has a key.
- return renderModelDestructive(
+ const prevImplicitSlot = task.implicitSlot;
+ if (task.keyPath === null) {
+ task.implicitSlot = true;
+ }
+ const json = renderModelDestructive(
request,
task,
emptyRoot,
'',
props.children,
);
+ task.implicitSlot = prevImplicitSlot;
+ return json;
}
// This might be a built-in React component. We'll let the client decide.
// Any built-in works as long as its props are serializable.
- return [REACT_ELEMENT_TYPE, type, key, props];
+ return renderClientElement(task, type, key, props);
} else if (type != null && typeof type === 'object') {
if (isClientReference(type)) {
// This is a reference to a Client Component.
- return [REACT_ELEMENT_TYPE, type, key, props];
+ return renderClientElement(task, type, key, props);
}
switch (type.$$typeof) {
case REACT_LAZY_TYPE: {
@@ -596,7 +708,29 @@ function renderElement(
prepareToUseHooksForComponent(prevThenableState);
const result = render(props, undefined);
- return renderModelDestructive(request, task, emptyRoot, '', result);
+ const prevKeyPath = task.keyPath;
+ const prevImplicitSlot = task.implicitSlot;
+ if (key !== null) {
+ // Append the key to the path. Technically a null key should really add the child
+ // index. We don't do that to hold the payload small and implementation simple.
+ task.keyPath = prevKeyPath === null ? key : prevKeyPath + ',' + key;
+ } else if (prevKeyPath === null) {
+ // This sequence of Server Components has no keys. This means that it was rendered
+ // in a slot that needs to assign an implicit key. Even if children below have
+ // explicit keys, they should not be used for the outer most key since it might
+ // collide with other slots in that set.
+ task.implicitSlot = true;
+ }
+ const json = renderModelDestructive(
+ request,
+ task,
+ emptyRoot,
+ '',
+ result,
+ );
+ task.keyPath = prevKeyPath;
+ task.implicitSlot = prevImplicitSlot;
+ return json;
}
case REACT_MEMO_TYPE: {
return renderElement(request, task, type.type, key, ref, props);
@@ -618,13 +752,13 @@ function renderElement(
);
}
}
- return [
- REACT_ELEMENT_TYPE,
+ return renderClientElement(
+ task,
type,
key,
// Rely on __popProvider being serialized last to pop the provider.
{value: props.value, children: props.children, __pop: POP},
- ];
+ );
}
// Fallthrough
}
@@ -647,18 +781,31 @@ function pingTask(request: Request, task: Task): void {
function createTask(
request: Request,
model: ReactClientValue,
+ keyPath: null | string,
+ implicitSlot: boolean,
context: ContextSnapshot,
abortSet: Set,
): Task {
const id = request.nextChunkId++;
if (typeof model === 'object' && model !== null) {
- // Register this model as having the ID we're about to write.
- request.writtenObjects.set(model, id);
+ // If we're about to write this into a new task we can assign it an ID early so that
+ // any other references can refer to the value we're about to write.
+ if (
+ enableServerComponentKeys &&
+ (keyPath !== null || implicitSlot || context !== rootContextSnapshot)
+ ) {
+ // If we're in some kind of context we can't necessarily reuse this object depending
+ // what parent components are used.
+ } else {
+ request.writtenObjects.set(model, id);
+ }
}
const task: Task = {
id,
status: PENDING,
model,
+ keyPath,
+ implicitSlot,
context,
ping: () => pingTask(request, task),
toJSON: function (
@@ -855,7 +1002,9 @@ function outlineModel(request: Request, value: ReactClientValue): number {
const newTask = createTask(
request,
value,
- getActiveContext(),
+ null, // The way we use outlining is for reusing an object.
+ false, // It makes no sense for that use case to be contextual.
+ rootContextSnapshot, // Therefore we don't pass any contextual information along.
request.abortableTasks,
);
retryTask(request, newTask);
@@ -988,6 +1137,8 @@ function renderModel(
key: string,
value: ReactClientValue,
): ReactJSONValue {
+ const prevKeyPath = task.keyPath;
+ const prevImplicitSlot = task.implicitSlot;
try {
return renderModelDestructive(request, task, parent, key, value);
} catch (thrownValue) {
@@ -1016,12 +1167,20 @@ function renderModel(
const newTask = createTask(
request,
task.model,
- getActiveContext(),
+ task.keyPath,
+ task.implicitSlot,
+ task.context,
request.abortableTasks,
);
const ping = newTask.ping;
(x: any).then(ping, ping);
newTask.thenableState = getThenableStateAfterSuspending();
+
+ // Restore the context. We assume that this will be restored by the inner
+ // functions in case nothing throws so we don't use "finally" here.
+ task.keyPath = prevKeyPath;
+ task.implicitSlot = prevImplicitSlot;
+
if (wasReactNode) {
return serializeLazyID(newTask.id);
}
@@ -1034,12 +1193,24 @@ function renderModel(
const postponeId = request.nextChunkId++;
logPostpone(request, postponeInstance.message);
emitPostponeChunk(request, postponeId, postponeInstance);
+
+ // Restore the context. We assume that this will be restored by the inner
+ // functions in case nothing throws so we don't use "finally" here.
+ task.keyPath = prevKeyPath;
+ task.implicitSlot = prevImplicitSlot;
+
if (wasReactNode) {
return serializeLazyID(postponeId);
}
return serializeByValueID(postponeId);
}
}
+
+ // Restore the context. We assume that this will be restored by the inner
+ // functions in case nothing throws so we don't use "finally" here.
+ task.keyPath = prevKeyPath;
+ task.implicitSlot = prevImplicitSlot;
+
if (wasReactNode) {
// Something errored. We'll still send everything we have up until this point.
// We'll replace this element with a lazy reference that throws on the client
@@ -1089,18 +1260,31 @@ function renderModelDestructive(
const writtenObjects = request.writtenObjects;
const existingId = writtenObjects.get(value);
if (existingId !== undefined) {
- if (existingId === -1) {
- // Seen but not yet outlined.
- const newId = outlineModel(request, value);
- return serializeByValueID(newId);
+ if (
+ enableServerComponentKeys &&
+ (task.keyPath !== null ||
+ task.implicitSlot ||
+ task.context !== rootContextSnapshot)
+ ) {
+ // If we're in some kind of context we can't reuse the result of this render or
+ // previous renders of this element. We only reuse elements if they're not wrapped
+ // by another Server Component.
} else if (modelRoot === value) {
// This is the ID we're currently emitting so we need to write it
// once but if we discover it again, we refer to it by id.
modelRoot = null;
+ } else if (existingId === -1) {
+ // Seen but not yet outlined.
+ // TODO: If we throw here we can treat this as suspending which causes an outline
+ // but that is able to reuse the same task if we're already in one but then that
+ // will be a lazy future value rather than guaranteed to exist but maybe that's good.
+ const newId = outlineModel(request, (value: any));
+ return serializeLazyID(newId);
} else {
- // We've already emitted this as an outlined object, so we can
- // just refer to that by its existing ID.
- return serializeByValueID(existingId);
+ // We've already emitted this as an outlined object, so we can refer to that by its
+ // existing ID. We use a lazy reference since, unlike plain objects, elements might
+ // suspend so it might not have emitted yet even if we have the ID for it.
+ return serializeLazyID(existingId);
}
} else {
// This is the first time we've seen this object. We may never see it again
@@ -1108,13 +1292,13 @@ function renderModelDestructive(
writtenObjects.set(value, -1);
}
- // TODO: Concatenate keys of parents onto children.
const element: React$Element = (value: any);
// Attempt to render the Server Component.
return renderElement(
request,
task,
element.type,
+ // $FlowFixMe[incompatible-call] the key of an element is null | string
element.key,
element.ref,
element.props,
@@ -1155,7 +1339,18 @@ function renderModelDestructive(
// $FlowFixMe[method-unbinding]
if (typeof value.then === 'function') {
if (existingId !== undefined) {
- if (modelRoot === value) {
+ if (
+ enableServerComponentKeys &&
+ (task.keyPath !== null ||
+ task.implicitSlot ||
+ task.context !== rootContextSnapshot)
+ ) {
+ // If we're in some kind of context we can't reuse the result of this render or
+ // previous renders of this element. We only reuse Promises if they're not wrapped
+ // by another Server Component.
+ const promiseId = serializeThenable(request, task, (value: any));
+ return serializePromiseID(promiseId);
+ } else if (modelRoot === value) {
// This is the ID we're currently emitting so we need to write it
// once but if we discover it again, we refer to it by id.
modelRoot = null;
@@ -1166,7 +1361,7 @@ function renderModelDestructive(
}
// We assume that any object with a .then property is a "Thenable" type,
// or a Promise type. Either of which can be represented by a Promise.
- const promiseId = serializeThenable(request, (value: any));
+ const promiseId = serializeThenable(request, task, (value: any));
writtenObjects.set(value, promiseId);
return serializePromiseID(promiseId);
}
@@ -1195,14 +1390,14 @@ function renderModelDestructive(
}
if (existingId !== undefined) {
- if (existingId === -1) {
- // Seen but not yet outlined.
- const newId = outlineModel(request, value);
- return serializeByValueID(newId);
- } else if (modelRoot === value) {
+ if (modelRoot === value) {
// This is the ID we're currently emitting so we need to write it
// once but if we discover it again, we refer to it by id.
modelRoot = null;
+ } else if (existingId === -1) {
+ // Seen but not yet outlined.
+ const newId = outlineModel(request, (value: any));
+ return serializeByValueID(newId);
} else {
// We've already emitted this as an outlined object, so we can
// just refer to that by its existing ID.
@@ -1215,8 +1410,7 @@ function renderModelDestructive(
}
if (isArray(value)) {
- // $FlowFixMe[incompatible-return]
- return value;
+ return renderFragment(request, task, value);
}
if (value instanceof Map) {
@@ -1282,7 +1476,7 @@ function renderModelDestructive(
const iteratorFn = getIteratorFn(value);
if (iteratorFn) {
- return Array.from((value: any));
+ return renderFragment(request, task, Array.from((value: any)));
}
// Verify that this is a simple plain object.
@@ -1582,6 +1776,7 @@ function retryTask(request: Request, task: Task): void {
return;
}
+ const prevContext = getActiveContext();
switchContext(task.context);
try {
// Track the root so we know that we have to emit this object even though it
@@ -1602,15 +1797,22 @@ function retryTask(request: Request, task: Task): void {
// Track the root again for the resolved object.
modelRoot = resolvedModel;
- // If the value is a string, it means it's a terminal value adn we already escaped it
- // We don't need to escape it again so it's not passed the toJSON replacer.
- // Object might contain unresolved values like additional elements.
- // This is simulating what the JSON loop would do if this was part of it.
- // $FlowFixMe[incompatible-type] stringify can return null
- const json: string =
- typeof resolvedModel === 'string'
- ? stringify(resolvedModel)
- : stringify(resolvedModel, task.toJSON);
+ // The keyPath resets at any terminal child node.
+ task.keyPath = null;
+ task.implicitSlot = false;
+
+ let json: string;
+ if (typeof resolvedModel === 'object' && resolvedModel !== null) {
+ // Object might contain unresolved values like additional elements.
+ // This is simulating what the JSON loop would do if this was part of it.
+ // $FlowFixMe[incompatible-type] stringify can return null for undefined but we never do
+ json = stringify(resolvedModel, task.toJSON);
+ } else {
+ // If the value is a string, it means it's a terminal value and we already escaped it
+ // We don't need to escape it again so it's not passed the toJSON replacer.
+ // $FlowFixMe[incompatible-type] stringify can return null for undefined but we never do
+ json = stringify(resolvedModel);
+ }
emitModelChunk(request, task.id, json);
request.abortableTasks.delete(task);
@@ -1646,6 +1848,10 @@ function retryTask(request: Request, task: Task): void {
task.status = ERRORED;
const digest = logRecoverableError(request, x);
emitErrorChunk(request, task.id, digest, x);
+ } finally {
+ if (enableServerContext) {
+ switchContext(prevContext);
+ }
}
}
diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js
index 845ff9645a838..332e7f7bdff27 100644
--- a/packages/shared/ReactFeatureFlags.js
+++ b/packages/shared/ReactFeatureFlags.js
@@ -15,6 +15,8 @@
export const enableComponentStackLocations = true;
+export const enableServerComponentKeys = __EXPERIMENTAL__;
+
// -----------------------------------------------------------------------------
// Killswitch
//
diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js
index d98e4e508101f..2b314b6ff2a07 100644
--- a/packages/shared/forks/ReactFeatureFlags.native-fb.js
+++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js
@@ -93,5 +93,7 @@ export const enableFizzExternalRuntime = false;
export const enableAsyncActions = false;
export const enableUseDeferredValueInitialArg = true;
+export const enableServerComponentKeys = true;
+
// Flow magic to verify the exports of this file match the original version.
((((null: any): ExportsType): FeatureFlagsType): ExportsType);
diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js
index a8fb8c9ad740e..9bf741d74a278 100644
--- a/packages/shared/forks/ReactFeatureFlags.native-oss.js
+++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js
@@ -85,5 +85,7 @@ export const useMicrotasksForSchedulingInFabric = false;
export const passChildrenWhenCloningPersistedNodes = false;
export const enableUseDeferredValueInitialArg = __EXPERIMENTAL__;
+export const enableServerComponentKeys = true;
+
// Flow magic to verify the exports of this file match the original version.
((((null: any): ExportsType): FeatureFlagsType): ExportsType);
diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js
index fa019f07600d3..3dcf66f535a9c 100644
--- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js
+++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js
@@ -85,5 +85,7 @@ export const useMicrotasksForSchedulingInFabric = false;
export const passChildrenWhenCloningPersistedNodes = false;
export const enableUseDeferredValueInitialArg = __EXPERIMENTAL__;
+export const enableServerComponentKeys = true;
+
// Flow magic to verify the exports of this file match the original version.
((((null: any): ExportsType): FeatureFlagsType): ExportsType);
diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js
index 347ff62abf9af..a0eaf1f80966d 100644
--- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js
+++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js
@@ -82,5 +82,7 @@ export const useMicrotasksForSchedulingInFabric = false;
export const passChildrenWhenCloningPersistedNodes = false;
export const enableUseDeferredValueInitialArg = __EXPERIMENTAL__;
+export const enableServerComponentKeys = true;
+
// Flow magic to verify the exports of this file match the original version.
((((null: any): ExportsType): FeatureFlagsType): ExportsType);
diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js
index 236d46b4a5756..e97d010255afa 100644
--- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js
+++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js
@@ -85,5 +85,7 @@ export const useMicrotasksForSchedulingInFabric = false;
export const passChildrenWhenCloningPersistedNodes = false;
export const enableUseDeferredValueInitialArg = true;
+export const enableServerComponentKeys = true;
+
// Flow magic to verify the exports of this file match the original version.
((((null: any): ExportsType): FeatureFlagsType): ExportsType);
diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js
index 15f5b993c8254..f573a75b5c588 100644
--- a/packages/shared/forks/ReactFeatureFlags.www.js
+++ b/packages/shared/forks/ReactFeatureFlags.www.js
@@ -112,5 +112,7 @@ export const passChildrenWhenCloningPersistedNodes = false;
export const enableAsyncDebugInfo = false;
+export const enableServerComponentKeys = true;
+
// Flow magic to verify the exports of this file match the original version.
((((null: any): ExportsType): FeatureFlagsType): ExportsType);