From 10cc400184dfa3d5d3eb8b2e10903b87b9489cb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Thu, 11 Mar 2021 15:01:41 -0500 Subject: [PATCH] Basic Fizz Architecture (#20970) * Copy some infra structure patterns from Flight * Basic data structures * Move structural nodes and instruction commands to host config * Move instruction command to host config In the DOM this is implemented as script tags. The first time it's emitted it includes the function. Future calls invoke the same function. The side of the complete boundary function in particular is unfortunately large. * Implement Fizz Noop host configs This is implemented not as a serialized protocol but by-passing the serialization when possible and instead it's like a live tree being built. * Implement React Native host config This is not wired up. I just need something for the flow types since Flight and Fizz are both handled by the isServerSupported flag. Might as well add something though. The principle of this format is the same structure as for HTML but a simpler binary format. Each entry is a tag followed by some data and terminated by null. * Check in error codes * Comment --- .../src/ReactNoopFlightServer.js | 6 - .../src/ReactNoopServer.js | 192 +++- .../src/ReactDOMServerFormatConfig.js | 369 +++++++- packages/react-server/src/ReactFizzServer.js | 826 +++++++++++++++++- .../src/ReactNativeServerFormatConfig.js | 198 ++++- .../src/__tests__/ReactServer-test.js | 9 +- .../forks/ReactServerFormatConfig.custom.js | 25 +- scripts/error-codes/codes.json | 10 +- 8 files changed, 1564 insertions(+), 71 deletions(-) diff --git a/packages/react-noop-renderer/src/ReactNoopFlightServer.js b/packages/react-noop-renderer/src/ReactNoopFlightServer.js index f080f27add..bbace47e2e 100644 --- a/packages/react-noop-renderer/src/ReactNoopFlightServer.js +++ b/packages/react-noop-renderer/src/ReactNoopFlightServer.js @@ -36,12 +36,6 @@ const ReactNoopFlightServer = ReactFlightServer({ convertStringToBuffer(content: string): Uint8Array { return Buffer.from(content, 'utf8'); }, - formatChunkAsString(type: string, props: Object): string { - return JSON.stringify({type, props}); - }, - formatChunk(type: string, props: Object): Uint8Array { - return Buffer.from(JSON.stringify({type, props}), 'utf8'); - }, isModuleReference(reference: Object): boolean { return reference.$$typeof === Symbol.for('react.module.reference'); }, diff --git a/packages/react-noop-renderer/src/ReactNoopServer.js b/packages/react-noop-renderer/src/ReactNoopServer.js index 0f21bbd6dc..3be95ab051 100644 --- a/packages/react-noop-renderer/src/ReactNoopServer.js +++ b/packages/react-noop-renderer/src/ReactNoopServer.js @@ -16,7 +16,40 @@ import ReactFizzServer from 'react-server'; -type Destination = Array; +type Instance = {| + type: string, + children: Array, + prop: any, + hidden: boolean, +|}; + +type TextInstance = {| + text: string, + hidden: boolean, +|}; + +type SuspenseInstance = {| + state: 'pending' | 'complete' | 'client-render', + children: Array, +|}; + +type Placeholder = { + parent: Instance | SuspenseInstance, + index: number, +}; + +type Segment = { + children: null | Instance | TextInstance | SuspenseInstance, +}; + +type Destination = { + root: null | Instance | TextInstance | SuspenseInstance, + placeholders: Map, + segments: Map, + stack: Array, +}; + +const POP = Buffer.from('/', 'utf8'); const ReactNoopServer = ReactFizzServer({ scheduleWork(callback: () => void) { @@ -24,24 +57,165 @@ const ReactNoopServer = ReactFizzServer({ }, beginWriting(destination: Destination): void {}, writeChunk(destination: Destination, buffer: Uint8Array): void { - destination.push(JSON.parse(Buffer.from((buffer: any)).toString('utf8'))); + const stack = destination.stack; + if (buffer === POP) { + stack.pop(); + return; + } + // We assume one chunk is one instance. + const instance = JSON.parse(Buffer.from((buffer: any)).toString('utf8')); + if (stack.length === 0) { + destination.root = instance; + } else { + const parent = stack[stack.length - 1]; + parent.children.push(instance); + } + stack.push(instance); }, completeWriting(destination: Destination): void {}, close(destination: Destination): void {}, flushBuffered(destination: Destination): void {}, - convertStringToBuffer(content: string): Uint8Array { - return Buffer.from(content, 'utf8'); + + createResponseState(): null { + return null; }, - formatChunkAsString(type: string, props: Object): string { - return JSON.stringify({type, props}); + createSuspenseBoundaryID(): SuspenseInstance { + // The ID is a pointer to the boundary itself. + return {state: 'pending', children: []}; }, - formatChunk(type: string, props: Object): Uint8Array { - return Buffer.from(JSON.stringify({type, props}), 'utf8'); + + pushTextInstance(target: Array, text: string): void { + const textInstance: TextInstance = { + text, + hidden: false, + }; + target.push(Buffer.from(JSON.stringify(textInstance), 'utf8'), POP); + }, + pushStartInstance( + target: Array, + type: string, + props: Object, + ): void { + const instance: Instance = { + type: type, + children: [], + prop: props.prop, + hidden: false, + }; + target.push(Buffer.from(JSON.stringify(instance), 'utf8')); + }, + + pushEndInstance( + target: Array, + type: string, + props: Object, + ): void { + target.push(POP); + }, + + writePlaceholder(destination: Destination, id: number): boolean { + const parent = destination.stack[destination.stack.length - 1]; + destination.placeholders.set(id, { + parent: parent, + index: parent.children.length, + }); + }, + + writeStartCompletedSuspenseBoundary( + destination: Destination, + suspenseInstance: SuspenseInstance, + ): boolean { + suspenseInstance.state = 'complete'; + const parent = destination.stack[destination.stack.length - 1]; + parent.children.push(suspenseInstance); + destination.stack.push(suspenseInstance); + }, + writeStartPendingSuspenseBoundary( + destination: Destination, + suspenseInstance: SuspenseInstance, + ): boolean { + suspenseInstance.state = 'pending'; + const parent = destination.stack[destination.stack.length - 1]; + parent.children.push(suspenseInstance); + destination.stack.push(suspenseInstance); + }, + writeStartClientRenderedSuspenseBoundary( + destination: Destination, + suspenseInstance: SuspenseInstance, + ): boolean { + suspenseInstance.state = 'client-render'; + const parent = destination.stack[destination.stack.length - 1]; + parent.children.push(suspenseInstance); + destination.stack.push(suspenseInstance); + }, + writeEndSuspenseBoundary(destination: Destination): boolean { + destination.stack.pop(); + }, + + writeStartSegment(destination: Destination, id: number): boolean { + const segment = { + children: [], + }; + destination.segments.set(id, segment); + if (destination.stack.length > 0) { + throw new Error('Segments are only expected at the root of the stack.'); + } + destination.stack.push(segment); + }, + writeEndSegment(destination: Destination): boolean { + destination.stack.pop(); + }, + + writeCompletedSegmentInstruction( + destination: Destination, + responseState: ResponseState, + contentSegmentID: number, + ): boolean { + const segment = destination.segments.get(contentSegmentID); + if (!segment) { + throw new Error('Missing segment.'); + } + const placeholder = destination.placeholders.get(contentSegmentID); + if (!placeholder) { + throw new Error('Missing placeholder.'); + } + placeholder.parent.children.splice( + placeholder.index, + 0, + ...segment.children, + ); + }, + + writeCompletedBoundaryInstruction( + destination: Destination, + responseState: ResponseState, + boundary: SuspenseInstance, + contentSegmentID: number, + ): boolean { + const segment = destination.segments.get(contentSegmentID); + if (!segment) { + throw new Error('Missing segment.'); + } + boundary.children = segment.children; + boundary.state = 'complete'; + }, + + writeClientRenderBoundaryInstruction( + destination: Destination, + responseState: ResponseState, + boundary: SuspenseInstance, + ): boolean { + boundary.status = 'client-render'; }, }); function render(children: React$Element): Destination { - const destination: Destination = []; + const destination: Destination = { + root: null, + placeholders: new Map(), + segments: new Map(), + stack: [], + }; const request = ReactNoopServer.createRequest(children, destination); ReactNoopServer.startWork(request); return destination; diff --git a/packages/react-server/src/ReactDOMServerFormatConfig.js b/packages/react-server/src/ReactDOMServerFormatConfig.js index 1e36890e99..104834ca88 100644 --- a/packages/react-server/src/ReactDOMServerFormatConfig.js +++ b/packages/react-server/src/ReactDOMServerFormatConfig.js @@ -7,17 +7,368 @@ * @flow */ -import {convertStringToBuffer} from 'react-server/src/ReactServerStreamConfig'; +import type {Destination} from 'react-server/src/ReactServerStreamConfig'; -export function formatChunkAsString(type: string, props: Object): string { - let str = '<' + type + '>'; - if (typeof props.children === 'string') { - str += props.children; +import { + writeChunk, + convertStringToBuffer, +} from 'react-server/src/ReactServerStreamConfig'; + +import invariant from 'shared/invariant'; + +// Per response, +export type ResponseState = { + sentCompleteSegmentFunction: boolean, + sentCompleteBoundaryFunction: boolean, + sentClientRenderFunction: boolean, +}; + +// Allows us to keep track of what we've already written so we can refer back to it. +export function createResponseState(): ResponseState { + return { + sentCompleteSegmentFunction: false, + sentCompleteBoundaryFunction: false, + sentClientRenderFunction: false, + }; +} + +// This object is used to lazily reuse the ID of the first generated node, or assign one. +// We can't assign an ID up front because the node we're attaching it to might already +// have one. So we need to lazily use that if it's available. +export type SuspenseBoundaryID = { + id: null | string, +}; + +export function createSuspenseBoundaryID( + responseState: ResponseState, +): SuspenseBoundaryID { + return {id: null}; +} + +function encodeHTMLIDAttribute(value: string): string { + // TODO: This needs to be encoded for security purposes. + return value; +} + +function encodeHTMLTextNode(text: string): string { + // TOOD: This needs to be encoded for security purposes. + return text; +} + +export function pushTextInstance( + target: Array, + text: string, +): void { + target.push(convertStringToBuffer(encodeHTMLTextNode(text))); +} + +const startTag1 = convertStringToBuffer('<'); +const startTag2 = convertStringToBuffer('>'); + +export function pushStartInstance( + target: Array, + type: string, + props: Object, +): void { + // TODO: Figure out if it's self closing and everything else. + target.push(startTag1, convertStringToBuffer(type), startTag2); +} + +const endTag1 = convertStringToBuffer(''); + +export function pushEndInstance( + target: Array, + type: string, + props: Object, +): void { + // TODO: Figure out if it was self closing. + target.push(endTag1, convertStringToBuffer(type), endTag2); +} + +// Structural Nodes + +// A placeholder is a node inside a hidden partial tree that can be filled in later, but before +// display. It's never visible to users. +const placeholder1 = convertStringToBuffer(''); +export function writePlaceholder( + destination: Destination, + id: number, +): boolean { + // TODO: This needs to be contextually aware and switch tag since not all parents allow for spans like + //