From fa11bd6ecca57d8ddbc728df881f0e165225c04f Mon Sep 17 00:00:00 2001 From: mofeiZ <34200447+mofeiZ@users.noreply.github.com> Date: Wed, 30 Nov 2022 13:22:08 -0500 Subject: [PATCH] [ServerRenderer] Add option to send instructions as data attributes (#25437) ### Changes made: - Running with enableFizzExternalRuntime (feature flag) and unstable_externalRuntimeSrc (param) will generate html nodes with data attributes that encode Fizz instructions. ``` ``` - Added an external runtime browser script `ReactDOMServerExternalRuntime`, which processes and removes these nodes - This runtime should be passed as to renderInto[...] via `unstable_externalRuntimeSrc` - Since this runtime is render blocking (for all streamed suspense boundaries and segments), we want this to reach the client as early as possible. By default, Fizz will send this script at the end of the shell when it detects dynamic content (e.g. suspenseful pending tasks), but it can be sent even earlier by calling `preinit(...)` inside a component. - The current implementation relies on Float to dedupe sending `unstable_externalRuntimeSrc`, so `enableFizzExternalRuntime` is only valid when `enableFloat` is also set. --- .../src/server/ReactDOMFloatServer.js | 14 +- .../server/ReactDOMServerExternalRuntime.js | 102 +++- .../src/server/ReactDOMServerFormatConfig.js | 524 +++++++++++++++--- .../ReactDOMServerLegacyFormatConfig.js | 24 +- .../src/__tests__/ReactDOMFizzServer-test.js | 156 +++++- .../src/__tests__/ReactDOMFloat-test.js | 78 ++- .../src/server/ReactDOMLegacyServerImpl.js | 3 + .../react-dom/src/test-utils/FizzTestUtils.js | 79 ++- .../server/ReactNativeServerFormatConfig.js | 1 + .../src/ReactDOMServerFB.js | 3 + packages/react-server/src/ReactFizzServer.js | 9 +- scripts/error-codes/codes.json | 3 +- 12 files changed, 845 insertions(+), 151 deletions(-) diff --git a/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js b/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js index 4af71458cb6a7..2805b629d4b48 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js +++ b/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js @@ -275,7 +275,7 @@ type PreinitOptions = { crossOrigin?: string, integrity?: string, }; -function preinit(href: string, options: PreinitOptions) { +function preinit(href: string, options: PreinitOptions): void { if (!currentResources) { // While we expect that preinit calls are primarily going to be observed // during render because effects and events don't run on the server it is @@ -285,7 +285,17 @@ function preinit(href: string, options: PreinitOptions) { // simply return and do not warn. return; } - const resources = currentResources; + preinitImpl(currentResources, href, options); +} + +// On the server, preinit may be called outside of render when sending an +// external SSR runtime as part of the initial resources payload. Since this +// is an internal React call, we do not need to use the resources stack. +export function preinitImpl( + resources: Resources, + href: string, + options: PreinitOptions, +): void { if (__DEV__) { validatePreinitArguments(href, options); } diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerExternalRuntime.js b/packages/react-dom-bindings/src/server/ReactDOMServerExternalRuntime.js index 01f53697e2e5f..63f5b64d93c8c 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMServerExternalRuntime.js +++ b/packages/react-dom-bindings/src/server/ReactDOMServerExternalRuntime.js @@ -3,6 +3,7 @@ * clients. Therefore, it should be fast and not have many external dependencies. * @flow */ +/* eslint-disable dot-notation */ // Imports are resolved statically by the closure compiler in release bundles // and by rollup in jest unit tests @@ -13,13 +14,94 @@ import { completeSegment, } from './fizz-instruction-set/ReactDOMFizzInstructionSet'; -// Intentionally does nothing. Implementation will be added in future PR. -// eslint-disable-next-line no-unused-vars -const observer = new MutationObserver(mutations => { - // These are only called so I can check what the module output looks like. The - // code is unreachable. - clientRenderBoundary(); - completeBoundaryWithStyles(); - completeBoundary(); - completeSegment(); -}); +if (!window.$RC) { + // TODO: Eventually remove, we currently need to set these globals for + // compatibility with ReactDOMFizzInstructionSet + window.$RC = completeBoundary; + window.$RM = new Map(); +} + +if (document.readyState === 'loading') { + if (document.body != null) { + installFizzInstrObserver(document.body); + } else { + // body may not exist yet if the fizz runtime is sent in + // (e.g. as a preinit resource) + const domBodyObserver = new MutationObserver(() => { + // We expect the body node to be stable once parsed / created + if (document.body) { + if (document.readyState === 'loading') { + installFizzInstrObserver(document.body); + } + handleExistingNodes(); + domBodyObserver.disconnect(); + } + }); + // documentElement must already exist at this point + // $FlowFixMe[incompatible-call] + domBodyObserver.observe(document.documentElement, {childList: true}); + } +} + +handleExistingNodes(); + +function handleExistingNodes() { + const existingNodes = document.getElementsByTagName('template'); + for (let i = 0; i < existingNodes.length; i++) { + handleNode(existingNodes[i]); + } +} + +function installFizzInstrObserver(target /*: Node */) { + const fizzInstrObserver = new MutationObserver(mutations => { + for (let i = 0; i < mutations.length; i++) { + const addedNodes = mutations[i].addedNodes; + for (let j = 0; j < addedNodes.length; j++) { + if (addedNodes.item(j).parentNode) { + handleNode(addedNodes.item(j)); + } + } + } + }); + // We assume that instruction data nodes are eventually appended to the + // body, even if Fizz is streaming to a shell / subtree. + fizzInstrObserver.observe(target, { + childList: true, + }); + window.addEventListener('DOMContentLoaded', () => { + fizzInstrObserver.disconnect(); + }); +} + +function handleNode(node_ /*: Node */) { + // $FlowFixMe[incompatible-cast] + if (node_.nodeType !== 1 || !(node_ /*: HTMLElement*/).dataset) { + return; + } + // $FlowFixMe[incompatible-cast] + const node = (node_ /*: HTMLElement*/); + const dataset = node.dataset; + if (dataset['rxi'] != null) { + clientRenderBoundary( + dataset['bid'], + dataset['dgst'], + dataset['msg'], + dataset['stck'], + ); + node.remove(); + } else if (dataset['rri'] != null) { + // Convert styles here, since its type is Array> + completeBoundaryWithStyles( + dataset['bid'], + dataset['sid'], + JSON.parse(dataset['sty']), + ); + node.remove(); + } else if (dataset['rci'] != null) { + completeBoundary(dataset['bid'], dataset['sid']); + node.remove(); + } else if (dataset['rsi'] != null) { + completeSegment(dataset['sid'], dataset['pid']); + node.remove(); + } +} diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js index 91d241e4e7929..c66292d801835 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js @@ -63,6 +63,7 @@ import sanitizeURL from '../shared/sanitizeURL'; import isArray from 'shared/isArray'; import { + preinitImpl, prepareToRenderResources, finishRenderingResources, resourcesFromElement, @@ -105,22 +106,33 @@ export function cleanupAfterRender(previousDispatcher: mixed) { // E.g. this can be used to distinguish legacy renderers from this modern one. export const isPrimaryRenderer = true; +export type StreamingFormat = 0 | 1; +const ScriptStreamingFormat: StreamingFormat = 0; +const DataStreamingFormat: StreamingFormat = 1; + // Per response, global state that is not contextual to the rendering subtree. export type ResponseState = { bootstrapChunks: Array, - startInlineScript: PrecomputedChunk, placeholderPrefix: PrecomputedChunk, segmentPrefix: PrecomputedChunk, boundaryPrefix: string, idPrefix: string, nextSuspenseID: number, + streamingFormat: StreamingFormat, + // state for script streaming format, unused if using external runtime / data + startInlineScript: PrecomputedChunk, sentCompleteSegmentFunction: boolean, sentCompleteBoundaryFunction: boolean, sentClientRenderFunction: boolean, - sentStyleInsertionFunction: boolean, // We allow the legacy renderer to extend this object. + sentStyleInsertionFunction: boolean, + // state for data streaming format + externalRuntimeConfig: BootstrapScriptDescriptor | null, + // We allow the legacy renderer to extend this object. ... }; +const dataElementQuotedEnd = stringToPrecomputedChunk('">'); + const startInlineScript = stringToPrecomputedChunk(''); @@ -154,6 +166,8 @@ export type BootstrapScriptDescriptor = { integrity?: string, }; // Allows us to keep track of what we've already written so we can refer back to it. +// if passed externalRuntimeConfig and the enableFizzExternalRuntime feature flag +// is set, the server will send instructions via data attributes (instead of inline scripts) export function createResponseState( identifierPrefix: string | void, nonce: string | void, @@ -170,6 +184,8 @@ export function createResponseState( ''); +const completeSegmentScriptEnd = stringToPrecomputedChunk('")'); + +const completeSegmentData1 = stringToPrecomputedChunk( + '