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 1cbac757430f1..5d966a16ded59 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js @@ -242,6 +242,20 @@ describe('ReactFlightDOMEdge', () => { expect(result).toEqual(resolvedChildren); }); + it('should execute repeated server components in a compact form', async () => { + async function ServerComponent({recurse}) { + if (recurse > 0) { + return ; + } + return
Fin
; + } + const stream = ReactServerDOMServer.renderToReadableStream( + , + ); + const serializedContent = await readResult(stream); + expect(serializedContent.length).toBeLessThan(150); + }); + // @gate enableBinaryFlight it('should be able to serialize any kind of typed array', async () => { const buffer = new Uint8Array([ diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 16357649bbb3d..f77b455a2fa82 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -2201,8 +2201,12 @@ function renderNodeDestructive( task.node = node; task.childIndex = childIndex; + if (node === null) { + return; + } + // Handle object types - if (typeof node === 'object' && node !== null) { + if (typeof node === 'object') { switch ((node: any).$$typeof) { case REACT_ELEMENT_TYPE: { const element: any = node; diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 2ce2260a4852f..97f55ead28f42 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -137,7 +137,7 @@ type ReactJSONValue = | boolean | number | null - | $ReadOnlyArray + | $ReadOnlyArray | ReactClientObject; // Serializable values @@ -180,6 +180,7 @@ type Task = { status: 0 | 1 | 3 | 4, model: ReactClientValue, ping: () => void, + toJSON: (key: string, value: ReactClientValue) => ReactJSONValue, context: ContextSnapshot, thenableState: ThenableState | null, }; @@ -212,7 +213,6 @@ export type Request = { taintCleanupQueue: Array, onError: (error: mixed) => ?string, onPostpone: (reason: string) => void, - toJSON: (key: string, value: ReactClientValue) => ReactJSONValue, }; const { @@ -311,10 +311,6 @@ export function createRequest( taintCleanupQueue: cleanupQueue, onError: onError === undefined ? defaultErrorHandler : onError, onPostpone: onPostpone === undefined ? defaultPostponeHandler : onPostpone, - // $FlowFixMe[missing-this-annot] - toJSON: function (key: string, value: ReactClientValue): ReactJSONValue { - return resolveModelToJSON(request, this, key, value); - }, }; request.pendingChunks++; const rootContext = createRootContext(context); @@ -504,14 +500,15 @@ function createLazyWrapperAroundWakeable(wakeable: Wakeable) { return lazyType; } -function attemptResolveElement( +function renderElement( request: Request, + task: Task, type: any, key: null | React$Key, ref: mixed, props: any, prevThenableState: ThenableState | null, -): ReactClientValue { +): ReactJSONValue { if (ref !== null && ref !== undefined) { // When the ref moves to the regular props object this will implicitly // throw for functions. We could probably relax it to a DEV warning for other @@ -533,7 +530,7 @@ function attemptResolveElement( } // This is a server-side component. prepareToUseHooksForComponent(prevThenableState); - const result = type(props); + let result = type(props); if ( typeof result === 'object' && result !== null && @@ -547,9 +544,9 @@ function attemptResolveElement( } // TODO: Once we accept Promises as children on the client, we can just return // the thenable here. - return createLazyWrapperAroundWakeable(result); + result = createLazyWrapperAroundWakeable(result); } - return result; + return renderModelDestructive(request, task, emptyRoot, '', result, null); } else if (typeof type === 'string') { // This is a host element. E.g. HTML. return [REACT_ELEMENT_TYPE, type, key, props]; @@ -559,7 +556,14 @@ function attemptResolveElement( // 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 props.children; + return renderModelDestructive( + request, + task, + emptyRoot, + '', + props.children, + null, + ); } // 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. @@ -574,8 +578,9 @@ function attemptResolveElement( const payload = type._payload; const init = type._init; const wrappedType = init(payload); - return attemptResolveElement( + return renderElement( request, + task, wrappedType, key, ref, @@ -586,11 +591,20 @@ function attemptResolveElement( case REACT_FORWARD_REF_TYPE: { const render = type.render; prepareToUseHooksForComponent(prevThenableState); - return render(props, undefined); + const result = render(props, undefined); + return renderModelDestructive( + request, + task, + emptyRoot, + '', + result, + null, + ); } case REACT_MEMO_TYPE: { - return attemptResolveElement( + return renderElement( request, + task, type.type, key, ref, @@ -600,7 +614,7 @@ function attemptResolveElement( } case REACT_PROVIDER_TYPE: { if (enableServerContext) { - pushProvider(type._context, props.value); + task.context = pushProvider(type._context, props.value); if (__DEV__) { const extraKeys = Object.keys(props).filter(value => { if (value === 'children' || value === 'value') { @@ -648,12 +662,81 @@ function createTask( 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); + } const task: Task = { id, status: PENDING, model, context, ping: () => pingTask(request, task), + toJSON: function ( + this: + | {+[key: string | number]: ReactClientValue} + | $ReadOnlyArray, + parentPropertyName: string, + value: ReactClientValue, + ): ReactJSONValue { + const parent = this; + // Make sure that `parent[parentPropertyName]` wasn't JSONified before `value` was passed to us + if (__DEV__) { + // $FlowFixMe[incompatible-use] + const originalValue = parent[parentPropertyName]; + if ( + typeof originalValue === 'object' && + originalValue !== value && + !(originalValue instanceof Date) + ) { + if (objectName(originalValue) !== 'Object') { + const jsxParentType = jsxChildrenParents.get(parent); + if (typeof jsxParentType === 'string') { + console.error( + '%s objects cannot be rendered as text children. Try formatting it using toString().%s', + objectName(originalValue), + describeObjectForErrorMessage(parent, parentPropertyName), + ); + } else { + console.error( + 'Only plain objects can be passed to Client Components from Server Components. ' + + '%s objects are not supported.%s', + objectName(originalValue), + describeObjectForErrorMessage(parent, parentPropertyName), + ); + } + } else { + console.error( + 'Only plain objects can be passed to Client Components from Server Components. ' + + 'Objects with toJSON methods are not supported. Convert it manually ' + + 'to a simple value before passing it to props.%s', + describeObjectForErrorMessage(parent, parentPropertyName), + ); + } + } + + if ( + enableServerContext && + parent[0] === REACT_ELEMENT_TYPE && + parent[1] && + (parent[1]: any).$$typeof === REACT_PROVIDER_TYPE && + parentPropertyName === '3' + ) { + insideContextProps = value; + } else if ( + insideContextProps === parent && + parentPropertyName === 'value' + ) { + isInsideContextValue = true; + } else if ( + insideContextProps === parent && + parentPropertyName === 'children' + ) { + isInsideContextValue = false; + } + } + return renderModel(request, task, parent, parentPropertyName, value); + }, thenableState: null, }; abortSet.add(task); @@ -733,9 +816,9 @@ function encodeReferenceChunk( function serializeClientReference( request: Request, parent: - | {+[key: string | number]: ReactClientValue} + | {+[propertyName: string | number]: ReactClientValue} | $ReadOnlyArray, - key: string, + parentPropertyName: string, clientReference: ClientReference, ): string { const clientReferenceKey: ClientReferenceKey = @@ -743,7 +826,7 @@ function serializeClientReference( const writtenClientReferences = request.writtenClientReferences; const existingId = writtenClientReferences.get(clientReferenceKey); if (existingId !== undefined) { - if (parent[0] === REACT_ELEMENT_TYPE && key === '1') { + if (parent[0] === REACT_ELEMENT_TYPE && parentPropertyName === '1') { // If we're encoding the "type" of an element, we can refer // to that by a lazy reference instead of directly since React // knows how to deal with lazy values. This lets us suspend @@ -760,7 +843,7 @@ function serializeClientReference( const importId = request.nextChunkId++; emitImportChunk(request, importId, clientReferenceMetadata); writtenClientReferences.set(clientReferenceKey, importId); - if (parent[0] === REACT_ELEMENT_TYPE && key === '1') { + if (parent[0] === REACT_ELEMENT_TYPE && parentPropertyName === '1') { // If we're encoding the "type" of an element, we can refer // to that by a lazy reference instead of directly since React // knows how to deal with lazy values. This lets us suspend @@ -778,7 +861,7 @@ function serializeClientReference( } } -function outlineModel(request: Request, value: any): number { +function outlineModel(request: Request, value: ReactClientValue): number { request.pendingChunks++; const newTask = createTask( request, @@ -792,10 +875,6 @@ function outlineModel(request: Request, value: any): number { function serializeServerReference( request: Request, - parent: - | {+[key: string | number]: ReactClientValue} - | $ReadOnlyArray, - key: string, serverReference: ServerReference, ): string { const writtenServerReferences = request.writtenServerReferences; @@ -911,166 +990,68 @@ let insideContextProps = null; let isInsideContextValue = false; let modelRoot: null | ReactClientValue = false; -function resolveModelToJSON( +function renderModel( request: Request, + task: Task, parent: | {+[key: string | number]: ReactClientValue} | $ReadOnlyArray, key: string, value: ReactClientValue, ): ReactJSONValue { - // Make sure that `parent[key]` wasn't JSONified before `value` was passed to us - if (__DEV__) { - // $FlowFixMe[incompatible-use] - const originalValue = parent[key]; - if ( - typeof originalValue === 'object' && - originalValue !== value && - !(originalValue instanceof Date) - ) { - if (objectName(originalValue) !== 'Object') { - const jsxParentType = jsxChildrenParents.get(parent); - if (typeof jsxParentType === 'string') { - console.error( - '%s objects cannot be rendered as text children. Try formatting it using toString().%s', - objectName(originalValue), - describeObjectForErrorMessage(parent, key), - ); - } else { - console.error( - 'Only plain objects can be passed to Client Components from Server Components. ' + - '%s objects are not supported.%s', - objectName(originalValue), - describeObjectForErrorMessage(parent, key), - ); - } - } else { - console.error( - 'Only plain objects can be passed to Client Components from Server Components. ' + - 'Objects with toJSON methods are not supported. Convert it manually ' + - 'to a simple value before passing it to props.%s', - describeObjectForErrorMessage(parent, key), + try { + return renderModelDestructive(request, task, parent, key, value, null); + } catch (thrownValue) { + const x = + thrownValue === SuspenseException + ? // This is a special type of exception used for Suspense. For historical + // reasons, the rest of the Suspense implementation expects the thrown + // value to be a thenable, because before `use` existed that was the + // (unstable) API for suspending. This implementation detail can change + // later, once we deprecate the old API in favor of `use`. + getSuspendedThenable() + : thrownValue; + // If the suspended/errored value was an element or lazy it can be reduced + // to a lazy reference, so that it doesn't error the parent. + const model = task.model; + const wasReactNode = + typeof model === 'object' && + model !== null && + ((model: any).$$typeof === REACT_ELEMENT_TYPE || + (model: any).$$typeof === REACT_LAZY_TYPE); + if (typeof x === 'object' && x !== null) { + // $FlowFixMe[method-unbinding] + if (typeof x.then === 'function') { + // Something suspended, we'll need to create a new task and resolve it later. + request.pendingChunks++; + const newTask = createTask( + request, + task.model, + getActiveContext(), + request.abortableTasks, ); - } - } - } - - // Special Symbols - switch (value) { - case REACT_ELEMENT_TYPE: - return '$'; - } - - if (__DEV__) { - if ( - enableServerContext && - parent[0] === REACT_ELEMENT_TYPE && - parent[1] && - (parent[1]: any).$$typeof === REACT_PROVIDER_TYPE && - key === '3' - ) { - insideContextProps = value; - } else if (insideContextProps === parent && key === 'value') { - isInsideContextValue = true; - } else if (insideContextProps === parent && key === 'children') { - isInsideContextValue = false; - } - } - - // Resolve Server Components. - while ( - typeof value === 'object' && - value !== null && - ((value: any).$$typeof === REACT_ELEMENT_TYPE || - (value: any).$$typeof === REACT_LAZY_TYPE) - ) { - if (__DEV__) { - if (enableServerContext && isInsideContextValue) { - console.error('React elements are not allowed in ServerContext'); - } - } - - try { - switch ((value: any).$$typeof) { - case REACT_ELEMENT_TYPE: { - 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); - } 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 { - // We've already emitted this as an outlined object, so we can - // just refer to that by its existing ID. - return serializeByValueID(existingId); - } - } else { - // This is the first time we've seen this object. We may never see it again - // so we'll inline it. Mark it as seen. If we see it again, we'll outline. - writtenObjects.set(value, -1); - } - - // TODO: Concatenate keys of parents onto children. - const element: React$Element = (value: any); - // Attempt to render the Server Component. - value = attemptResolveElement( - request, - element.type, - element.key, - element.ref, - element.props, - null, - ); - break; - } - case REACT_LAZY_TYPE: { - const payload = (value: any)._payload; - const init = (value: any)._init; - value = init(payload); - break; - } - } - } catch (thrownValue) { - const x = - thrownValue === SuspenseException - ? // This is a special type of exception used for Suspense. For historical - // reasons, the rest of the Suspense implementation expects the thrown - // value to be a thenable, because before `use` existed that was the - // (unstable) API for suspending. This implementation detail can change - // later, once we deprecate the old API in favor of `use`. - getSuspendedThenable() - : thrownValue; - if (typeof x === 'object' && x !== null) { - // $FlowFixMe[method-unbinding] - if (typeof x.then === 'function') { - // Something suspended, we'll need to create a new task and resolve it later. - request.pendingChunks++; - const newTask = createTask( - request, - value, - getActiveContext(), - request.abortableTasks, - ); - const ping = newTask.ping; - x.then(ping, ping); - newTask.thenableState = getThenableStateAfterSuspending(); + const ping = newTask.ping; + (x: any).then(ping, ping); + newTask.thenableState = getThenableStateAfterSuspending(); + if (wasReactNode) { return serializeLazyID(newTask.id); - } else if (enablePostpone && x.$$typeof === REACT_POSTPONE_TYPE) { - // Something postponed. We'll still send everything we have up until this point. - // We'll replace this element with a lazy reference that postpones on the client. - const postponeInstance: Postpone = (x: any); - request.pendingChunks++; - const postponeId = request.nextChunkId++; - logPostpone(request, postponeInstance.message); - emitPostponeChunk(request, postponeId, postponeInstance); + } + return serializeByValueID(newTask.id); + } else if (enablePostpone && x.$$typeof === REACT_POSTPONE_TYPE) { + // Something postponed. We'll still send everything we have up until this point. + // We'll replace this element with a lazy reference that postpones on the client. + const postponeInstance: Postpone = (x: any); + request.pendingChunks++; + const postponeId = request.nextChunkId++; + logPostpone(request, postponeInstance.message); + emitPostponeChunk(request, postponeId, postponeInstance); + if (wasReactNode) { return serializeLazyID(postponeId); } + return serializeByValueID(postponeId); } + } + 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 // once it gets rendered. @@ -1080,6 +1061,29 @@ function resolveModelToJSON( emitErrorChunk(request, errorId, digest, x); return serializeLazyID(errorId); } + // Something errored but it was not in a React Node. There's no need to serialize + // it by value because it'll just error the whole parent row anyway so we can + // just stop any siblings and error the whole parent row. + throw x; + } +} + +function renderModelDestructive( + request: Request, + task: Task, + parent: + | {+[propertyName: string | number]: ReactClientValue} + | $ReadOnlyArray, + parentPropertyName: string, + value: ReactClientValue, + prevThenableState: ThenableState | null, +): ReactJSONValue { + // Set the currently rendering model + task.model = value; + + // Special Symbol, that's very common. + if (value === REACT_ELEMENT_TYPE) { + return '$'; } if (value === null) { @@ -1087,15 +1091,78 @@ function resolveModelToJSON( } if (typeof value === 'object') { + switch ((value: any).$$typeof) { + case REACT_ELEMENT_TYPE: { + if (__DEV__) { + if (enableServerContext && isInsideContextValue) { + console.error('React elements are not allowed in ServerContext'); + } + } + 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); + } 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 { + // We've already emitted this as an outlined object, so we can + // just refer to that by its existing ID. + return serializeByValueID(existingId); + } + } else { + // This is the first time we've seen this object. We may never see it again + // so we'll inline it. Mark it as seen. If we see it again, we'll outline. + 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, + element.key, + element.ref, + element.props, + prevThenableState, + ); + } + case REACT_LAZY_TYPE: { + const payload = (value: any)._payload; + const init = (value: any)._init; + const resolvedModel = init(payload); + return renderModelDestructive( + request, + task, + emptyRoot, + '', + resolvedModel, + null, + ); + } + } + + if (isClientReference(value)) { + return serializeClientReference( + request, + parent, + parentPropertyName, + (value: any), + ); + } + if (enableTaint) { const tainted = TaintRegistryObjects.get(value); if (tainted !== undefined) { throwTaintViolation(tainted); } } - if (isClientReference(value)) { - return serializeClientReference(request, parent, key, (value: any)); - } const writtenObjects = request.writtenObjects; const existingId = writtenObjects.get(value); @@ -1123,7 +1190,7 @@ function resolveModelToJSON( const providerKey = ((value: any): ReactProviderType)._context ._globalName; const writtenProviders = request.writtenProviders; - let providerId = writtenProviders.get(key); + let providerId = writtenProviders.get(providerKey); if (providerId === undefined) { request.pendingChunks++; providerId = request.nextChunkId++; @@ -1132,7 +1199,7 @@ function resolveModelToJSON( } return serializeByValueID(providerId); } else if (value === POP) { - popProvider(); + task.context = popProvider(); if (__DEV__) { insideContextProps = null; isInsideContextValue = false; @@ -1249,13 +1316,13 @@ function resolveModelToJSON( 'Only plain objects can be passed to Client Components from Server Components. ' + '%s objects are not supported.%s', objectName(value), - describeObjectForErrorMessage(parent, key), + describeObjectForErrorMessage(parent, parentPropertyName), ); } else if (!isSimpleObject(value)) { console.error( 'Only plain objects can be passed to Client Components from Server Components. ' + 'Classes or other objects with methods are not supported.%s', - describeObjectForErrorMessage(parent, key), + describeObjectForErrorMessage(parent, parentPropertyName), ); } else if (Object.getOwnPropertySymbols) { const symbols = Object.getOwnPropertySymbols(value); @@ -1264,7 +1331,7 @@ function resolveModelToJSON( 'Only plain objects can be passed to Client Components from Server Components. ' + 'Objects with symbol properties like %s are not supported.%s', symbols[0].description, - describeObjectForErrorMessage(parent, key), + describeObjectForErrorMessage(parent, parentPropertyName), ); } } @@ -1285,7 +1352,7 @@ function resolveModelToJSON( if (value[value.length - 1] === 'Z') { // Possibly a Date, whose toJSON automatically calls toISOString // $FlowFixMe[incompatible-use] - const originalValue = parent[key]; + const originalValue = parent[parentPropertyName]; if (originalValue instanceof Date) { return serializeDateFromDateJSON(value); } @@ -1312,29 +1379,36 @@ function resolveModelToJSON( } if (typeof value === 'function') { + if (isClientReference(value)) { + return serializeClientReference( + request, + parent, + parentPropertyName, + (value: any), + ); + } + if (isServerReference(value)) { + return serializeServerReference(request, (value: any)); + } + if (enableTaint) { const tainted = TaintRegistryObjects.get(value); if (tainted !== undefined) { throwTaintViolation(tainted); } } - if (isClientReference(value)) { - return serializeClientReference(request, parent, key, (value: any)); - } - if (isServerReference(value)) { - return serializeServerReference(request, parent, key, (value: any)); - } - if (/^on[A-Z]/.test(key)) { + + if (/^on[A-Z]/.test(parentPropertyName)) { throw new Error( 'Event handlers cannot be passed to Client Component props.' + - describeObjectForErrorMessage(parent, key) + + describeObjectForErrorMessage(parent, parentPropertyName) + '\nIf you need interactivity, consider converting part of this to a Client Component.', ); } else { throw new Error( 'Functions cannot be passed directly to Client Components ' + 'unless you explicitly expose it by marking it with "use server".' + - describeObjectForErrorMessage(parent, key), + describeObjectForErrorMessage(parent, parentPropertyName), ); } } @@ -1355,7 +1429,7 @@ function resolveModelToJSON( // $FlowFixMe[incompatible-type] `description` might be undefined value.description }) cannot be found among global symbols.` + - describeObjectForErrorMessage(parent, key), + describeObjectForErrorMessage(parent, parentPropertyName), ); } @@ -1378,7 +1452,7 @@ function resolveModelToJSON( throw new Error( `Type ${typeof value} is not supported in Client Component props.` + - describeObjectForErrorMessage(parent, key), + describeObjectForErrorMessage(parent, parentPropertyName), ); } @@ -1508,22 +1582,14 @@ function emitProviderChunk( request.completedRegularChunks.push(processedChunk); } -function emitModelChunk( - request: Request, - id: number, - model: ReactClientValue, -): void { - // Track the root so we know that we have to emit this object even though it - // already has an ID. This is needed because we might see this object twice - // in the same toJSON if it is cyclic. - modelRoot = model; - // $FlowFixMe[incompatible-type] stringify can return null - const json: string = stringify(model, request.toJSON); +function emitModelChunk(request: Request, id: number, json: string): void { const row = id.toString(16) + ':' + json + '\n'; const processedChunk = stringToChunk(row); request.completedRegularChunks.push(processedChunk); } +const emptyRoot = {}; + function retryTask(request: Request, task: Task): void { if (task.status !== PENDING) { // We completed this by other means before we had a chance to retry it. @@ -1532,67 +1598,42 @@ function retryTask(request: Request, task: Task): void { switchContext(task.context); try { - let value = task.model; - if ( - typeof value === 'object' && - value !== null && - (value: any).$$typeof === REACT_ELEMENT_TYPE - ) { - request.writtenObjects.set(value, task.id); - - // TODO: Concatenate keys of parents onto children. - const element: React$Element = (value: any); - - // When retrying a component, reuse the thenableState from the - // previous attempt. - const prevThenableState = task.thenableState; - - // Attempt to render the Server Component. - // Doing this here lets us reuse this same task if the next component - // also suspends. - task.model = value; - value = attemptResolveElement( - request, - element.type, - element.key, - element.ref, - element.props, - prevThenableState, - ); + // Reset the task's thenable state before continuing, so that if a later + // component suspends we can reuse the same task object. If the same + // component suspends again, the thenable state will be restored. + const prevThenableState = task.thenableState; + task.thenableState = null; + + // Track the root so we know that we have to emit this object even though it + // already has an ID. This is needed because we might see this object twice + // in the same toJSON if it is cyclic. + modelRoot = task.model; + + // We call the destructive form that mutates this task. That way if something + // suspends again, we can reuse the same task instead of spawning a new one. + const resolvedModel = renderModelDestructive( + request, + task, + emptyRoot, + '', + task.model, + prevThenableState, + ); - // Successfully finished this component. We're going to keep rendering - // using the same task, but we reset its thenable state before continuing. - task.thenableState = null; - - // Keep rendering and reuse the same task. This inner loop is separate - // from the render above because we don't need to reset the thenable state - // until the next time something suspends and retries. - while ( - typeof value === 'object' && - value !== null && - (value: any).$$typeof === REACT_ELEMENT_TYPE - ) { - request.writtenObjects.set(value, task.id); - // TODO: Concatenate keys of parents onto children. - const nextElement: React$Element = (value: any); - task.model = value; - value = attemptResolveElement( - request, - nextElement.type, - nextElement.key, - nextElement.ref, - nextElement.props, - null, - ); - } - } + // Track the root again for the resolved object. + modelRoot = resolvedModel; - // Track that this object is outlined and has an id. - if (typeof value === 'object' && value !== null) { - request.writtenObjects.set(value, task.id); - } + // 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); + emitModelChunk(request, task.id, json); - emitModelChunk(request, task.id, value); request.abortableTasks.delete(task); task.status = COMPLETED; } catch (thrownValue) {