From 8622483ba30874384b3398186e5fbeabd62bc56c Mon Sep 17 00:00:00 2001 From: Josh Story Date: Tue, 28 Mar 2023 21:49:21 -0700 Subject: [PATCH] Flight support for Float Supporting Float methods such as ReactDOM.preload() are challenging for flight because it does not have an easy means to convey direct executions in other environments. Because the flight wire format is a JSON-like serialization that is expected to be rendered it currently only describes renderable elements. We need a way to convey a function invocation that gets run in the context of the client environment whether that is Fizz or Fiber. Fiber is somewhat straightforward because the HostDispatcher is always active and we can just have the FlightClient dispatch the serialized directive. Fizz is much more challenging becaue the dispatcher is always scoped but the specific request the dispatch belongs to is not readily available. For environments that support AsyncLocalStorage we could use that to provide the right Request context when dispatching the directives as they stream in however it would but ineffective in the environments without this feature. Instead we stash a "Store" on the Flight client request object and then set it just before dispatching and unset it when the dispatch is complete. This commit also adds support for AsyncLocalStorage in Fizz however this is more about supporting Float functions after an await point given we expect to offer support for async function components in this runtime soon. For Flight, if AsyncLocalStorage is available Float methods can be called after await points. If AsyncLocalStorage is not available float methods after await points are noops and you get a warning (in Dev). --- .../react-client/src/ReactFlightClient.js | 11 + .../src/ReactFlightClientHostConfigStream.js | 1 + .../src/ReactFlightClientStream.js | 9 + .../ReactFlightClientHostConfig.custom.js | 2 + ...ReactFlightClientHostConfig.dom-browser.js | 1 + .../ReactFlightClientHostConfig.dom-bun.js | 1 + ...FlightClientHostConfig.dom-edge-webpack.js | 1 + .../ReactFlightClientHostConfig.dom-legacy.js | 1 + ...FlightClientHostConfig.dom-node-webpack.js | 1 + .../ReactFlightClientHostConfig.dom-node.js | 1 + .../ReactFlightClientHostConfig.dom-relay.js | 1 + ...eactFlightClientHostConfig.native-relay.js | 2 + .../src/client/ReactDOMHostConfig.js | 88 ++----- .../ReactDOMFlightServerFormatConfig.js | 99 ++++++++ .../ReactDOMLegacyServerStreamConfig.js | 1 + .../src/server/ReactDOMServerFormatConfig.js | 142 +++++++---- .../ReactDOMServerLegacyFormatConfig.js | 1 + .../src/shared/ReactDOMDispatcher.js | 40 +++ .../shared/ReactDOMFlightClientHostConfig.js | 92 +++++++ packages/react-dom/src/ReactDOMFloat.js | 58 ++++- .../react-dom/src/ReactDOMSharedInternals.js | 7 +- .../src/__tests__/ReactDOMFloat-test.js | 8 +- packages/react-dom/src/client/ReactDOMRoot.js | 10 +- .../ReactNativeFlightServerFormatConfig.js | 10 + .../server/ReactNativeServerFormatConfig.js | 1 + .../ReactNativeFlightClientHostConfig.js | 13 + .../src/ReactNoopFlightServer.js | 1 + .../src/ReactNoopServer.js | 1 + .../src/ReactFlightDOMRelayProtocol.js | 1 + .../ReactFlightDOMRelayServerHostConfig.js | 17 +- .../src/ReactServerStreamConfigFB.js | 9 +- .../src/__tests__/ReactFlightDOM-test.js | 229 ++++++++++++++++++ .../__tests__/ReactFlightDOMBrowser-test.js | 224 +++++++++++++++++ .../react-server/src/ReactFizzResources.js | 35 +++ packages/react-server/src/ReactFizzServer.js | 16 +- packages/react-server/src/ReactFlightCache.js | 4 +- .../react-server/src/ReactFlightDirectives.js | 37 +++ .../react-server/src/ReactFlightNewContext.js | 2 +- .../react-server/src/ReactFlightServer.js | 43 +++- .../ReactFlightServerBundlerConfigCustom.js | 1 + .../src/ReactFlightServerConfigStream.js | 9 + .../src/ReactServerStreamConfigBrowser.js | 9 +- .../src/ReactServerStreamConfigBun.js | 8 +- .../src/ReactServerStreamConfigEdge.js | 8 +- .../src/ReactServerStreamConfigNode.js | 13 +- .../forks/ReactFlightServerConfig.custom.js | 3 + .../ReactFlightServerConfig.dom-browser.js | 1 + .../forks/ReactFlightServerConfig.dom-bun.js | 1 + ...eactFlightServerConfig.dom-edge-webpack.js | 1 + .../ReactFlightServerConfig.dom-legacy.js | 1 + ...eactFlightServerConfig.dom-node-webpack.js | 1 + .../forks/ReactFlightServerConfig.dom-node.js | 1 + .../ReactFlightServerConfig.dom-relay.js | 1 + .../ReactFlightServerConfig.native-relay.js | 1 + .../forks/ReactServerFormatConfig.custom.js | 1 + .../forks/ReactServerStreamConfig.custom.js | 1 + 56 files changed, 1129 insertions(+), 153 deletions(-) create mode 100644 packages/react-dom-bindings/src/server/ReactDOMFlightServerFormatConfig.js create mode 100644 packages/react-dom-bindings/src/shared/ReactDOMDispatcher.js create mode 100644 packages/react-dom-bindings/src/shared/ReactDOMFlightClientHostConfig.js create mode 100644 packages/react-native-renderer/src/server/ReactNativeFlightServerFormatConfig.js create mode 100644 packages/react-native-renderer/src/shared/ReactNativeFlightClientHostConfig.js create mode 100644 packages/react-server/src/ReactFizzResources.js create mode 100644 packages/react-server/src/ReactFlightDirectives.js diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 32fb4f1c907db..ba45ac11f051e 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -23,6 +23,7 @@ import { preloadModule, requireModule, parseModel, + dispatchDirective, } from './ReactFlightClientHostConfig'; import {knownServerReferences} from './ReactFlightServerReferenceRegistry'; @@ -758,6 +759,16 @@ export function resolveErrorDev( } } +export function resolveDirective( + response: Response, + id: number, + model: UninitializedModel, +): void { + const store = response._store; + const payload = JSON.parse(model); + dispatchDirective(store, payload); +} + export function close(response: Response): void { // In case there are any remaining unresolved chunks, they won't // be resolved now. So we need to issue an error to those. diff --git a/packages/react-client/src/ReactFlightClientHostConfigStream.js b/packages/react-client/src/ReactFlightClientHostConfigStream.js index 1a06961a1109e..8538c01fed639 100644 --- a/packages/react-client/src/ReactFlightClientHostConfigStream.js +++ b/packages/react-client/src/ReactFlightClientHostConfigStream.js @@ -14,6 +14,7 @@ export type Response = ResponseBase & { _partialRow: string, _fromJSON: (key: string, value: JSONValue) => any, _stringDecoder: StringDecoder, + _store: mixed, }; export type UninitializedModel = string; diff --git a/packages/react-client/src/ReactFlightClientStream.js b/packages/react-client/src/ReactFlightClientStream.js index 56772ec4a42b3..87b95045945d4 100644 --- a/packages/react-client/src/ReactFlightClientStream.js +++ b/packages/react-client/src/ReactFlightClientStream.js @@ -16,6 +16,7 @@ import { resolveModel, resolveErrorProd, resolveErrorDev, + resolveDirective, createResponse as createResponseBase, parseModelString, parseModelTuple, @@ -26,6 +27,7 @@ import { readFinalStringChunk, supportsBinaryStreams, createStringDecoder, + resolveStore, } from './ReactFlightClientHostConfig'; export type {Response}; @@ -46,6 +48,10 @@ function processFullRow(response: Response, row: string): void { resolveModule(response, id, row.substring(colon + 2)); return; } + case 'D': { + resolveDirective(response, id, row.substring(colon + 2)); + return; + } case 'E': { const errorInfo = JSON.parse(row.substring(colon + 2)); if (__DEV__) { @@ -134,6 +140,9 @@ export function createResponse( } // Don't inline this call because it causes closure to outline the call above. response._fromJSON = createFromJSONCallback(response); + // If the Host provides a store stash it on the response so we can provide it back + // to the dispatcher when resolving directives + response._store = resolveStore(); return response; } diff --git a/packages/react-client/src/forks/ReactFlightClientHostConfig.custom.js b/packages/react-client/src/forks/ReactFlightClientHostConfig.custom.js index 30810a69ebeb0..f52bec6075b54 100644 --- a/packages/react-client/src/forks/ReactFlightClientHostConfig.custom.js +++ b/packages/react-client/src/forks/ReactFlightClientHostConfig.custom.js @@ -35,6 +35,8 @@ export const resolveClientReference = $$$hostConfig.resolveClientReference; export const resolveServerReference = $$$hostConfig.resolveServerReference; export const preloadModule = $$$hostConfig.preloadModule; export const requireModule = $$$hostConfig.requireModule; +export const resolveStore = $$$hostConfig.resolveStore; +export const dispatchDirective = $$$hostConfig.dispatchDirective; export opaque type Source = mixed; diff --git a/packages/react-client/src/forks/ReactFlightClientHostConfig.dom-browser.js b/packages/react-client/src/forks/ReactFlightClientHostConfig.dom-browser.js index 4aae8141fd56e..6d99059b62b71 100644 --- a/packages/react-client/src/forks/ReactFlightClientHostConfig.dom-browser.js +++ b/packages/react-client/src/forks/ReactFlightClientHostConfig.dom-browser.js @@ -10,3 +10,4 @@ export * from 'react-client/src/ReactFlightClientHostConfigBrowser'; export * from 'react-client/src/ReactFlightClientHostConfigStream'; export * from 'react-server-dom-webpack/src/ReactFlightClientWebpackBundlerConfig'; +export * from 'react-dom-bindings/src/shared/ReactDOMFlightClientHostConfig'; diff --git a/packages/react-client/src/forks/ReactFlightClientHostConfig.dom-bun.js b/packages/react-client/src/forks/ReactFlightClientHostConfig.dom-bun.js index 28a1f34997f91..5a535251a79c1 100644 --- a/packages/react-client/src/forks/ReactFlightClientHostConfig.dom-bun.js +++ b/packages/react-client/src/forks/ReactFlightClientHostConfig.dom-bun.js @@ -9,6 +9,7 @@ export * from 'react-client/src/ReactFlightClientHostConfigBrowser'; export * from 'react-client/src/ReactFlightClientHostConfigStream'; +export * from 'react-dom-bindings/src/shared/ReactDOMFlightClientHostConfig'; export type Response = any; export opaque type SSRManifest = mixed; diff --git a/packages/react-client/src/forks/ReactFlightClientHostConfig.dom-edge-webpack.js b/packages/react-client/src/forks/ReactFlightClientHostConfig.dom-edge-webpack.js index 4aae8141fd56e..6d99059b62b71 100644 --- a/packages/react-client/src/forks/ReactFlightClientHostConfig.dom-edge-webpack.js +++ b/packages/react-client/src/forks/ReactFlightClientHostConfig.dom-edge-webpack.js @@ -10,3 +10,4 @@ export * from 'react-client/src/ReactFlightClientHostConfigBrowser'; export * from 'react-client/src/ReactFlightClientHostConfigStream'; export * from 'react-server-dom-webpack/src/ReactFlightClientWebpackBundlerConfig'; +export * from 'react-dom-bindings/src/shared/ReactDOMFlightClientHostConfig'; diff --git a/packages/react-client/src/forks/ReactFlightClientHostConfig.dom-legacy.js b/packages/react-client/src/forks/ReactFlightClientHostConfig.dom-legacy.js index 4aae8141fd56e..6d99059b62b71 100644 --- a/packages/react-client/src/forks/ReactFlightClientHostConfig.dom-legacy.js +++ b/packages/react-client/src/forks/ReactFlightClientHostConfig.dom-legacy.js @@ -10,3 +10,4 @@ export * from 'react-client/src/ReactFlightClientHostConfigBrowser'; export * from 'react-client/src/ReactFlightClientHostConfigStream'; export * from 'react-server-dom-webpack/src/ReactFlightClientWebpackBundlerConfig'; +export * from 'react-dom-bindings/src/shared/ReactDOMFlightClientHostConfig'; diff --git a/packages/react-client/src/forks/ReactFlightClientHostConfig.dom-node-webpack.js b/packages/react-client/src/forks/ReactFlightClientHostConfig.dom-node-webpack.js index 8b9b2defedff5..f12539929eb6b 100644 --- a/packages/react-client/src/forks/ReactFlightClientHostConfig.dom-node-webpack.js +++ b/packages/react-client/src/forks/ReactFlightClientHostConfig.dom-node-webpack.js @@ -10,3 +10,4 @@ export * from 'react-client/src/ReactFlightClientHostConfigNode'; export * from 'react-client/src/ReactFlightClientHostConfigStream'; export * from 'react-server-dom-webpack/src/ReactFlightClientWebpackBundlerConfig'; +export * from 'react-dom-bindings/src/shared/ReactDOMFlightClientHostConfig'; diff --git a/packages/react-client/src/forks/ReactFlightClientHostConfig.dom-node.js b/packages/react-client/src/forks/ReactFlightClientHostConfig.dom-node.js index 5c20adb286414..c5ceb09ffeec5 100644 --- a/packages/react-client/src/forks/ReactFlightClientHostConfig.dom-node.js +++ b/packages/react-client/src/forks/ReactFlightClientHostConfig.dom-node.js @@ -10,3 +10,4 @@ export * from 'react-client/src/ReactFlightClientHostConfigNode'; export * from 'react-client/src/ReactFlightClientHostConfigStream'; export * from 'react-server-dom-webpack/src/ReactFlightClientNodeBundlerConfig'; +export * from 'react-dom-bindings/src/shared/ReactDOMFlightClientHostConfig'; diff --git a/packages/react-client/src/forks/ReactFlightClientHostConfig.dom-relay.js b/packages/react-client/src/forks/ReactFlightClientHostConfig.dom-relay.js index ccde93cb29597..d5f0e4e755c64 100644 --- a/packages/react-client/src/forks/ReactFlightClientHostConfig.dom-relay.js +++ b/packages/react-client/src/forks/ReactFlightClientHostConfig.dom-relay.js @@ -9,3 +9,4 @@ export * from 'react-server-dom-relay/src/ReactFlightDOMRelayClientHostConfig'; export * from '../ReactFlightClientHostConfigNoStream'; +export * from 'react-dom-bindings/src/shared/ReactDOMFlightClientHostConfig'; diff --git a/packages/react-client/src/forks/ReactFlightClientHostConfig.native-relay.js b/packages/react-client/src/forks/ReactFlightClientHostConfig.native-relay.js index 7916d16829d04..3d9559f7a7836 100644 --- a/packages/react-client/src/forks/ReactFlightClientHostConfig.native-relay.js +++ b/packages/react-client/src/forks/ReactFlightClientHostConfig.native-relay.js @@ -7,5 +7,7 @@ * @flow */ +export * from 'react-server-native-relay/src/ReactFlightNativeRelayClientHostConfig'; export * from 'react-server-native-relay/src/ReactFlightNativeRelayClientHostConfig'; export * from '../ReactFlightClientHostConfigNoStream'; +export * from 'react-native-renderer/src/shared/ReactNativeFlightClientHostConfig'; diff --git a/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js b/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js index 48b1f41b85fc7..3247513dc4585 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js @@ -7,6 +7,7 @@ * @flow */ +import type {HostDispatcher} from 'react-dom-bindings/src/shared/ReactDOMDispatcher'; import type {EventPriority} from 'react-reconciler/src/ReactEventPriorities'; import type {DOMEventName} from '../events/DOMEventNames'; import type {Fiber, FiberRoot} from 'react-reconciler/src/ReactInternalTypes'; @@ -1940,23 +1941,14 @@ export function prepareToCommitHoistables() { // appropriate for resources that don't really have a strict tie to the document itself for example // preloads let lastCurrentDocument: ?Document = null; -let previousDispatcher = null; export function prepareRendererToRender(rootContainer: Container) { if (enableFloat) { const rootNode = getHoistableRoot(rootContainer); lastCurrentDocument = getDocumentFromRoot(rootNode); - - previousDispatcher = Dispatcher.current; - Dispatcher.current = ReactDOMClientDispatcher; } } -export function resetRendererAfterRender() { - if (enableFloat) { - Dispatcher.current = previousDispatcher; - previousDispatcher = null; - } -} +export function resetRendererAfterRender() {} // global collections of Resources const preloadPropsMap: Map = new Map(); @@ -1979,13 +1971,7 @@ function getCurrentResourceRoot(): null | HoistableRoot { return currentContainer ? getHoistableRoot(currentContainer) : null; } -// Preloads are somewhat special. Even if we don't have the Document -// used by the root that is rendering a component trying to insert a preload -// we can still seed the file cache by doing the preload on any document we have -// access to. We prefer the currentDocument if it exists, we also prefer the -// lastCurrentDocument if that exists. As a fallback we will use the window.document -// if available. -function getDocumentForPreloads(): ?Document { +function getBestEffortDocument(): ?Document { const root = getCurrentResourceRoot(); if (root) { return root.ownerDocument || root; @@ -1998,6 +1984,19 @@ function getDocumentForPreloads(): ?Document { } } +function getBestEffortRoot(): ?HoistableRoot { + const root = getCurrentResourceRoot(); + if (root) { + return root; + } else { + try { + return lastCurrentDocument || window.document; + } catch (error) { + return null; + } + } +} + function getDocumentFromRoot(root: HoistableRoot): Document { return root.ownerDocument || root; } @@ -2005,7 +2004,7 @@ function getDocumentFromRoot(root: HoistableRoot): Document { // We want this to be the default dispatcher on ReactDOMSharedInternals but we don't want to mutate // internals in Module scope. Instead we export it and Internals will import it. There is already a cycle // from Internals -> ReactDOM -> HostConfig -> Internals so this doesn't introduce a new one. -export const ReactDOMClientDispatcher = { +export const ReactDOMClientDispatcher: HostDispatcher = { prefetchDNS, preconnect, preload, @@ -2017,7 +2016,7 @@ function preconnectAs( crossOrigin: null | '' | 'use-credentials', href: string, ) { - const ownerDocument = getDocumentForPreloads(); + const ownerDocument = getBestEffortDocument(); if (typeof href === 'string' && href && ownerDocument) { const limitedEscapedHref = escapeSelectorAttributeValueInsideDoubleQuotes(href); @@ -2039,7 +2038,7 @@ function preconnectAs( } } -function prefetchDNS(href: string, options?: mixed) { +function prefetchDNS(href: string, options?: ?mixed) { if (__DEV__) { if (typeof href !== 'string' || !href) { console.error( @@ -2066,7 +2065,7 @@ function prefetchDNS(href: string, options?: mixed) { preconnectAs('dns-prefetch', null, href); } -function preconnect(href: string, options?: {crossOrigin?: string}) { +function preconnect(href: string, options?: ?{crossOrigin?: string}) { if (__DEV__) { if (typeof href !== 'string' || !href) { console.error( @@ -2096,7 +2095,7 @@ function preconnect(href: string, options?: {crossOrigin?: string}) { type PreloadAs = ResourceType; type PreloadOptions = { - as: PreloadAs, + as: string, crossOrigin?: string, integrity?: string, type?: string, @@ -2105,7 +2104,7 @@ function preload(href: string, options: PreloadOptions) { if (__DEV__) { validatePreloadArguments(href, options); } - const ownerDocument = getDocumentForPreloads(); + const ownerDocument = getBestEffortDocument(); if ( typeof href === 'string' && href && @@ -2142,7 +2141,7 @@ function preload(href: string, options: PreloadOptions) { function preloadPropsFromPreloadOptions( href: string, - as: ResourceType, + as: string, options: PreloadOptions, ): PreloadProps { return { @@ -2157,7 +2156,7 @@ function preloadPropsFromPreloadOptions( type PreinitAs = 'style' | 'script'; type PreinitOptions = { - as: PreinitAs, + as: string, precedence?: string, crossOrigin?: string, integrity?: string, @@ -2173,45 +2172,10 @@ function preinit(href: string, options: PreinitOptions) { typeof options === 'object' && options !== null ) { - const resourceRoot = getCurrentResourceRoot(); + const resourceRoot = getBestEffortRoot(); const as = options.as; if (!resourceRoot) { - if (as === 'style' || as === 'script') { - // We are going to emit a preload as a best effort fallback since this preinit - // was called outside of a render. Given the passive nature of this fallback - // we do not warn in dev when props disagree if there happens to already be a - // matching preload with this href - const preloadDocument = getDocumentForPreloads(); - if (preloadDocument) { - const limitedEscapedHref = - escapeSelectorAttributeValueInsideDoubleQuotes(href); - const preloadKey = `link[rel="preload"][as="${as}"][href="${limitedEscapedHref}"]`; - let key = preloadKey; - switch (as) { - case 'style': - key = getStyleKey(href); - break; - case 'script': - key = getScriptKey(href); - break; - } - if (!preloadPropsMap.has(key)) { - const preloadProps = preloadPropsFromPreinitOptions( - href, - as, - options, - ); - preloadPropsMap.set(key, preloadProps); - - if (null === preloadDocument.querySelector(preloadKey)) { - const instance = preloadDocument.createElement('link'); - setInitialProperties(instance, 'link', preloadProps); - markNodeAsHoistable(instance); - (preloadDocument.head: any).appendChild(instance); - } - } - } - } + // If we don't have a root to preinit into we just do nothing. return; } diff --git a/packages/react-dom-bindings/src/server/ReactDOMFlightServerFormatConfig.js b/packages/react-dom-bindings/src/server/ReactDOMFlightServerFormatConfig.js new file mode 100644 index 0000000000000..e8bd5059008d4 --- /dev/null +++ b/packages/react-dom-bindings/src/server/ReactDOMFlightServerFormatConfig.js @@ -0,0 +1,99 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import {enableFloat} from 'shared/ReactFeatureFlags'; + +import type { + Chunk, + PrecomputedChunk, +} from 'react-server/src/ReactServerStreamConfig'; + +import {resolveDirectives} from 'react-server/src/ReactFlightDirectives'; + +import ReactDOMSharedInternals from 'shared/ReactDOMSharedInternals'; +const ReactDOMCurrentDispatcher = ReactDOMSharedInternals.Dispatcher; + +const ReactDOMFlightServerDispatcher = { + prefetchDNS, + preconnect, + preload, + preinit, +}; + +export function prepareHostDispatcher(): void { + ReactDOMCurrentDispatcher.current = ReactDOMFlightServerDispatcher; +} + +// Used to distinguish these contexts from ones used in other renderers. +// E.g. this can be used to distinguish legacy renderers from this modern one. +export const isPrimaryRenderer = true; + +let didWarnAsyncEnvironmentDev = false; + +export function prefetchDNS(href: string, options?: mixed) { + if (enableFloat) { + pushDirective('prefetchDNS', href, options); + } +} + +export function preconnect(href: string, options?: ?{crossOrigin?: string}) { + if (enableFloat) { + pushDirective('preconnect', href, options); + } +} + +type PreloadAs = 'style' | 'font' | 'script'; +type PreloadOptions = { + as: string, + crossOrigin?: string, + integrity?: string, + type?: string, +}; + +export function preload(href: string, options: PreloadOptions) { + if (enableFloat) { + pushDirective('preload', href, options); + } +} + +type PreinitAs = 'style' | 'script'; +type PreinitOptions = { + as: string, + precedence?: string, + crossOrigin?: string, + integrity?: string, +}; +export function preinit(href: string, options: PreinitOptions): void { + if (enableFloat) { + pushDirective('preinit', href, options); + } +} + +function pushDirective( + method: 'prefetchDNS' | 'preconnect' | 'preload' | 'preinit', + href: string, + options: mixed, +): void { + const directives = resolveDirectives(); + if (directives === null) { + if (__DEV__) { + if (!didWarnAsyncEnvironmentDev) { + didWarnAsyncEnvironmentDev = true; + console.error( + 'ReactDOM.%s(): React expected to be able to associate this call to a specific Request but cannot. It is possible that this call was invoked outside of a React component. If you are calling it from within a React component that is an async function after the first `await` then you are in an environment which does not support AsyncLocalStorage. In this kind of environment ReactDOM.%s() does not do anything when called in an async manner. Try moving this function call above the first `await` within the component or remove this call. In environments that support AsyncLocalStorage such as Node.js you can call this method anywhere in a React component even after `await` operator.', + method, + method, + ); + } + } + return; + } + // @TODO need to escape + directives.push(JSON.stringify({method, args: [href, options]})); +} diff --git a/packages/react-dom-bindings/src/server/ReactDOMLegacyServerStreamConfig.js b/packages/react-dom-bindings/src/server/ReactDOMLegacyServerStreamConfig.js index f5a5356312fbe..c07bd229e43ce 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMLegacyServerStreamConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMLegacyServerStreamConfig.js @@ -23,6 +23,7 @@ export function flushBuffered(destination: Destination) {} export const supportsRequestStorage = false; export const requestStorage: AsyncLocalStorage = (null: any); +export const requestStorage2: AsyncLocalStorage = (null: any); export function beginWriting(destination: Destination) {} diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js index 60d53197bd44a..5e98059c19567 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js @@ -39,6 +39,12 @@ import { } from 'react-server/src/ReactServerStreamConfig'; import isAttributeNameSafe from '../shared/isAttributeNameSafe'; +import { + resolveResources, + setCurrentResources, + getCurrentResources, +} from 'react-server/src/ReactFizzResources'; + import { getPropertyInfo, BOOLEAN, @@ -84,30 +90,38 @@ import { import ReactDOMSharedInternals from 'shared/ReactDOMSharedInternals'; const ReactDOMCurrentDispatcher = ReactDOMSharedInternals.Dispatcher; -const ReactDOMServerDispatcher = enableFloat - ? { - prefetchDNS, - preconnect, - preload, - preinit, - } - : {}; +const ReactDOMServerDispatcher = { + prefetchDNS, + preconnect, + preload, + preinit, + resolveStore, + pushStore, + popStore, +}; let currentResources: null | Resources = null; const currentResourcesStack = []; -export function prepareToRender(resources: Resources): mixed { - currentResourcesStack.push(currentResources); - currentResources = resources; +function pushResources(resources: null | Resources) { + currentResourcesStack.push(getCurrentResources()); + setCurrentResources(resources); +} + +function popResources() { + setCurrentResources(currentResourcesStack.pop()); +} - const previousHostDispatcher = ReactDOMCurrentDispatcher.current; +export function prepareHostDispatcher() { ReactDOMCurrentDispatcher.current = ReactDOMServerDispatcher; - return previousHostDispatcher; } -export function cleanupAfterRender(previousDispatcher: mixed) { - currentResources = currentResourcesStack.pop(); - ReactDOMCurrentDispatcher.current = previousDispatcher; +export function prepareToRender(resources: Resources): mixed { + pushResources(resources); +} + +export function cleanupAfterRender() { + popResources(); } // Used to distinguish these contexts from ones used in other renderers. @@ -4347,17 +4361,34 @@ function getResourceKey(as: string, href: string): string { return `[${as}]${href}`; } +export type Store = Resources; +function resolveStore(): null | Resources { + // In Fizz DOM Store is Resources. Other environments may have a different store shape + return resolveResources(); +} + +function pushStore(store: null | Store) { + const resources: null | Resources = store; + pushResources(resources); +} + +function popStore() { + popResources(); +} + export function prefetchDNS(href: string, options?: mixed) { - if (!currentResources) { - // While we expect that preconnect calls are primarily going to be observed - // during render because effects and events don't run on the server it is - // still possible that these get called in module scope. This is valid on - // the client since there is still a document to interact with but on the - // server we need a request to associate the call to. Because of this we - // simply return and do not warn. + if (!enableFloat) { + return; + } + const resources = resolveResources(); + if (!resources) { + // In async contexts we can sometimes resolve resources from AsyncLocalStorage. If we can't we can also + // possibly get them from the stack if we are not in an async context. Since we were not able to resolve + // the resources for this call in either case we opt to do nothing. We can consider making this a warning + // but there may be times where calling a function outside of render is intentional (i.e. to warm up data + // fetching) and we don't want to warn in those cases. return; } - const resources = currentResources; if (__DEV__) { if (typeof href !== 'string' || !href) { console.error( @@ -4402,17 +4433,19 @@ export function prefetchDNS(href: string, options?: mixed) { } } -export function preconnect(href: string, options?: {crossOrigin?: string}) { - if (!currentResources) { - // While we expect that preconnect calls are primarily going to be observed - // during render because effects and events don't run on the server it is - // still possible that these get called in module scope. This is valid on - // the client since there is still a document to interact with but on the - // server we need a request to associate the call to. Because of this we - // simply return and do not warn. +export function preconnect(href: string, options?: ?{crossOrigin?: string}) { + if (!enableFloat) { + return; + } + const resources = resolveResources(); + if (!resources) { + // In async contexts we can sometimes resolve resources from AsyncLocalStorage. If we can't we can also + // possibly get them from the stack if we are not in an async context. Since we were not able to resolve + // the resources for this call in either case we opt to do nothing. We can consider making this a warning + // but there may be times where calling a function outside of render is intentional (i.e. to warm up data + // fetching) and we don't want to warn in those cases. return; } - const resources = currentResources; if (__DEV__) { if (typeof href !== 'string' || !href) { console.error( @@ -4463,22 +4496,24 @@ export function preconnect(href: string, options?: {crossOrigin?: string}) { type PreloadAs = 'style' | 'font' | 'script'; type PreloadOptions = { - as: PreloadAs, + as: string, crossOrigin?: string, integrity?: string, type?: string, }; export function preload(href: string, options: PreloadOptions) { - if (!currentResources) { - // While we expect that preload calls are primarily going to be observed - // during render because effects and events don't run on the server it is - // still possible that these get called in module scope. This is valid on - // the client since there is still a document to interact with but on the - // server we need a request to associate the call to. Because of this we - // simply return and do not warn. + if (!enableFloat) { + return; + } + const resources = resolveResources(); + if (!resources) { + // In async contexts we can sometimes resolve resources from AsyncLocalStorage. If we can't we can also + // possibly get them from the stack if we are not in an async context. Since we were not able to resolve + // the resources for this call in either case we opt to do nothing. We can consider making this a warning + // but there may be times where calling a function outside of render is intentional (i.e. to warm up data + // fetching) and we don't want to warn in those cases. return; } - const resources = currentResources; if (__DEV__) { if (typeof href !== 'string' || !href) { console.error( @@ -4604,22 +4639,25 @@ export function preload(href: string, options: PreloadOptions) { type PreinitAs = 'style' | 'script'; type PreinitOptions = { - as: PreinitAs, + as: string, precedence?: string, crossOrigin?: string, integrity?: string, }; export 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 - // still possible that these get called in module scope. This is valid on - // the client since there is still a document to interact with but on the - // server we need a request to associate the call to. Because of this we - // simply return and do not warn. + if (!enableFloat) { return; } - preinitImpl(currentResources, href, options); + const resources = resolveResources(); + if (!resources) { + // In async contexts we can sometimes resolve resources from AsyncLocalStorage. If we can't we can also + // possibly get them from the stack if we are not in an async context. Since we were not able to resolve + // the resources for this call in either case we opt to do nothing. We can consider making this a warning + // but there may be times where calling a function outside of render is intentional (i.e. to warm up data + // fetching) and we don't want to warn in those cases. + return; + } + preinitImpl(resources, href, options); } // On the server, preinit may be called outside of render when sending an @@ -4841,7 +4879,7 @@ function preinitImpl( function preloadPropsFromPreloadOptions( href: string, - as: PreloadAs, + as: string, options: PreloadOptions, ): PreloadProps { return { diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js b/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js index 5ca51fbcbbac0..68879489addfe 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js @@ -137,6 +137,7 @@ export { writePostamble, hoistResources, setCurrentlyRenderingBoundaryResourcesTarget, + prepareHostDispatcher, prepareToRender, cleanupAfterRender, } from './ReactDOMServerFormatConfig'; diff --git a/packages/react-dom-bindings/src/shared/ReactDOMDispatcher.js b/packages/react-dom-bindings/src/shared/ReactDOMDispatcher.js new file mode 100644 index 0000000000000..81c67d63189bd --- /dev/null +++ b/packages/react-dom-bindings/src/shared/ReactDOMDispatcher.js @@ -0,0 +1,40 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export type PrefetchDNSOptions = void; +export type PreconnectOptions = {crossOrigin?: string}; +export type PreloadOptions = { + as: string, + crossOrigin?: string, + integrity?: string, + type?: string, +}; +export type PreinitOptions = { + as: string, + precedence?: string, + crossOrigin?: string, + integrity?: string, +}; + +export type HostDispatcherWithStore = { + prefetchDNS: (href: string, options?: ?PrefetchDNSOptions) => void, + preconnect: (href: string, options?: ?PreconnectOptions) => void, + preload: (href: string, options: PreloadOptions) => void, + preinit: (href: string, options: PreinitOptions) => void, + resolveStore: () => null | Store, + pushStore: (null | Store) => void, + popStore: () => void, +}; + +export type HostDispatcher = { + prefetchDNS: (href: string, options?: ?PrefetchDNSOptions) => void, + preconnect: (href: string, options?: ?PreconnectOptions) => void, + preload: (href: string, options: PreloadOptions) => void, + preinit: (href: string, options: PreinitOptions) => void, +}; diff --git a/packages/react-dom-bindings/src/shared/ReactDOMFlightClientHostConfig.js b/packages/react-dom-bindings/src/shared/ReactDOMFlightClientHostConfig.js new file mode 100644 index 0000000000000..2c5030d7b5934 --- /dev/null +++ b/packages/react-dom-bindings/src/shared/ReactDOMFlightClientHostConfig.js @@ -0,0 +1,92 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +// This client file is in the shared folder because it applies to both SSR and browser contexts. +// It is the configuraiton of the FlightClient behavior which can run in either environment. + +import type {HostDispatcherWithStore} from './ReactDOMDispatcher'; + +import ReactDOMSharedInternals from 'shared/ReactDOMSharedInternals'; +const ReactDOMCurrentDispatcher = ReactDOMSharedInternals.Dispatcher; + +type DirectivePayload = mixed; + +export function dispatchDirective( + store: null | mixed, + payload: DirectivePayload, +): void { + const dispatcher = ReactDOMCurrentDispatcher.current; + if ( + payload === null || + typeof payload !== 'object' || + typeof payload.method !== 'string' || + !Array.isArray(payload.args) + ) { + if (__DEV__) { + console.error( + 'React expected a directive to be an object with method (string) and args (array) properties but encountered something else instead. It is likely this is a bug in React.', + ); + } + return; + } + const method = payload.method; + const args = payload.args; + if (dispatcher) { + if (typeof dispatcher.pushStore === 'function') { + // $FlowFixMe: Flow should be able to figure out that if the dispatcher has pushStore it has all the store methods + const dispatcherWithStore: HostDispatcherWithStore = + (dispatcher: any); + switch (method) { + case 'prefetchDNS': + dispatcherWithStore.pushStore(store); + dispatcherWithStore.prefetchDNS.apply(dispatcher, (args: any)); + dispatcherWithStore.popStore(); + return; + case 'preconnect': + dispatcherWithStore.pushStore(store); + dispatcherWithStore.preconnect.apply(dispatcher, (args: any)); + dispatcherWithStore.popStore(); + return; + case 'preload': + dispatcherWithStore.pushStore(store); + dispatcherWithStore.preload.apply(dispatcher, (args: any)); + dispatcherWithStore.popStore(); + return; + case 'preinit': + dispatcherWithStore.pushStore(store); + dispatcherWithStore.preinit.apply(dispatcher, (args: any)); + dispatcherWithStore.popStore(); + return; + } + } else { + switch (method) { + case 'prefetchDNS': + dispatcher.prefetchDNS.apply(dispatcher, (args: any)); + return; + case 'preconnect': + dispatcher.preconnect.apply(dispatcher, (args: any)); + return; + case 'preload': + dispatcher.preload.apply(dispatcher, (args: any)); + return; + case 'preinit': + dispatcher.preinit.apply(dispatcher, (args: any)); + return; + } + } + } +} + +export function resolveStore(): null | mixed { + const dispatcher = ReactDOMCurrentDispatcher.current; + if (dispatcher && typeof dispatcher.resolveStore === 'function') { + return dispatcher.resolveStore(); + } + return null; +} diff --git a/packages/react-dom/src/ReactDOMFloat.js b/packages/react-dom/src/ReactDOMFloat.js index 99a867286d361..158058f964b60 100644 --- a/packages/react-dom/src/ReactDOMFloat.js +++ b/packages/react-dom/src/ReactDOMFloat.js @@ -1,39 +1,73 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ +import type { + PreconnectOptions, + PrefetchDNSOptions, + PreloadOptions, + PreinitOptions, +} from './ReactDOMDispatcher'; + import ReactDOMSharedInternals from 'shared/ReactDOMSharedInternals'; +const Dispatcher = ReactDOMSharedInternals.Dispatcher; -export function prefetchDNS() { - const dispatcher = ReactDOMSharedInternals.Dispatcher.current; +export function prefetchDNS(href: string) { + let passedOptionArg: any; + if (__DEV__) { + if (arguments[1] !== undefined) { + passedOptionArg = arguments[1]; + } + } + const dispatcher = Dispatcher.current; if (dispatcher) { - dispatcher.prefetchDNS.apply(this, arguments); + if (__DEV__) { + if (passedOptionArg !== undefined) { + // prefetchDNS will warn if you pass reserved options arg. We pass it along in Dev only to + // elicit the warning. In prod we do not forward since it is not a part of the interface. + // @TODO move all arg validation into this file. It needs to be universal anyway so may as well lock down the interace here and + // let the rest of the codebase trust the types + dispatcher.prefetchDNS(href, passedOptionArg); + } else { + dispatcher.prefetchDNS(href); + } + } else { + dispatcher.prefetchDNS(href); + } } // We don't error because preconnect needs to be resilient to being called in a variety of scopes // and the runtime may not be capable of responding. The function is optimistic and not critical // so we favor silent bailout over warning or erroring. } -export function preconnect() { - const dispatcher = ReactDOMSharedInternals.Dispatcher.current; +export function preconnect(href: string, options?: ?PreconnectOptions) { + const dispatcher = Dispatcher.current; if (dispatcher) { - dispatcher.preconnect.apply(this, arguments); + dispatcher.preconnect(href, options); } // We don't error because preconnect needs to be resilient to being called in a variety of scopes // and the runtime may not be capable of responding. The function is optimistic and not critical // so we favor silent bailout over warning or erroring. } -export function preload() { - const dispatcher = ReactDOMSharedInternals.Dispatcher.current; +export function preload(href: string, options: PreloadOptions) { + const dispatcher = Dispatcher.current; if (dispatcher) { - dispatcher.preload.apply(this, arguments); + dispatcher.preload(href, options); } // We don't error because preload needs to be resilient to being called in a variety of scopes // and the runtime may not be capable of responding. The function is optimistic and not critical // so we favor silent bailout over warning or erroring. } -export function preinit() { - const dispatcher = ReactDOMSharedInternals.Dispatcher.current; +export function preinit(href: string, options: PreinitOptions) { + const dispatcher = Dispatcher.current; if (dispatcher) { - dispatcher.preinit.apply(this, arguments); + dispatcher.preinit(href, options); } // We don't error because preinit needs to be resilient to being called in a variety of scopes // and the runtime may not be capable of responding. The function is optimistic and not critical diff --git a/packages/react-dom/src/ReactDOMSharedInternals.js b/packages/react-dom/src/ReactDOMSharedInternals.js index a9e0407b006b2..5ec2e406edecc 100644 --- a/packages/react-dom/src/ReactDOMSharedInternals.js +++ b/packages/react-dom/src/ReactDOMSharedInternals.js @@ -7,11 +7,16 @@ * @flow */ +import type { + HostDispatcher, + HostDispatcherWithStore, +} from 'react-dom-bindings/src/shared/ReactDOMDispatcher'; + type InternalsType = { usingClientEntryPoint: boolean, Events: [any, any, any, any, any, any], Dispatcher: { - current: mixed, + current: null | HostDispatcher | HostDispatcherWithStore, }, }; diff --git a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js index d362e1b94a4a9..7b01f0423926c 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js @@ -3750,7 +3750,7 @@ body { }); // @gate enableFloat - it('creates a preload resource when ReactDOM.preinit(..., {as: "style" }) is called outside of render on the client', async () => { + it('creates a stylesheet resource when ReactDOM.preinit(..., {as: "style" }) is called outside of render on the client', async () => { function App() { React.useEffect(() => { ReactDOM.preinit('foo', {as: 'style'}); @@ -3768,7 +3768,7 @@ body { expect(getMeaningfulChildren(document)).toEqual( - + foo , @@ -3854,7 +3854,7 @@ body { }); // @gate enableFloat - it('creates a preload resource when ReactDOM.preinit(..., {as: "script" }) is called outside of render on the client', async () => { + it('creates a script resource when ReactDOM.preinit(..., {as: "script" }) is called outside of render on the client', async () => { function App() { React.useEffect(() => { ReactDOM.preinit('foo', {as: 'script'}); @@ -3872,7 +3872,7 @@ body { expect(getMeaningfulChildren(document)).toEqual( - +