From 09c091dd1ccd30f9a8418414f5cadf413050292f Mon Sep 17 00:00:00 2001 From: Josh Story Date: Fri, 10 Jun 2022 09:50:31 -0700 Subject: [PATCH 01/35] [Fizz/Float] Float for stylesheet resources This commit implements Float in Fizz and on the Client. The initial set of supported APIs is roughly 1. Convert certain stylesheets into style Resources when opting in with precedence prop 2. Emit preloads for stylesheets and explicit preload tags 3. Dedupe all Resources by href 4. Implement ReactDOM.preload() to allow for imperative preloading 5. Implement ReactDOM.preinit() to allow for imperative preinitialization Currently supports 1. style Resources (link rel "stylesheet") 2. font Resources (preload as "font") later updates will include support for scripts and modules --- packages/react-art/src/ReactARTHostConfig.js | 1 + .../src/client/ReactDOMComponentTree.js | 12 +- .../src/client/ReactDOMFloatClient.js | 825 ++++ .../src/client/ReactDOMHostConfig.js | 135 +- .../src/events/DOMPluginEventSystem.js | 23 +- .../src/server/ReactDOMFloatServer.js | 580 +++ .../src/server/ReactDOMServerFormatConfig.js | 861 +++- .../ReactDOMServerLegacyFormatConfig.js | 14 + .../src/shared/ReactDOMDispatcher.js | 18 + .../src/shared/ReactDOMFloat.js | 21 + .../src/shared/ReactDOMResourceValidation.js | 601 +++ packages/react-dom/index.experimental.js | 2 + .../react-dom/src/ReactDOMSharedInternals.js | 4 +- .../src/__tests__/ReactDOMFizzServer-test.js | 262 +- .../src/__tests__/ReactDOMFloat-test.js | 3473 +++++++++++++++++ .../src/test-utils/ReactTestUtils.js | 5 +- .../src/ReactFabricHostConfig.js | 1 + .../src/ReactNativeHostConfig.js | 1 + .../server/ReactNativeServerFormatConfig.js | 40 + .../src/ReactNoopServer.js | 19 + .../src/createReactNoop.js | 2 + .../react-reconciler/src/ReactFiber.new.js | 11 +- .../react-reconciler/src/ReactFiber.old.js | 11 +- .../src/ReactFiberBeginWork.new.js | 28 + .../src/ReactFiberBeginWork.old.js | 28 + .../src/ReactFiberCommitWork.new.js | 78 +- .../src/ReactFiberCommitWork.old.js | 78 +- .../src/ReactFiberCompleteWork.new.js | 23 + .../src/ReactFiberCompleteWork.old.js | 23 + .../src/ReactFiberComponentStack.js | 2 + .../ReactFiberHostConfigWithNoHydration.js | 2 - .../ReactFiberHostConfigWithNoResources.js | 29 + .../src/ReactFiberHotReloading.new.js | 7 +- .../src/ReactFiberHotReloading.old.js | 7 +- .../src/ReactFiberHydrationContext.new.js | 41 - .../src/ReactFiberHydrationContext.old.js | 41 - .../src/ReactFiberTreeReflection.js | 14 +- .../src/ReactFiberUnwindWork.new.js | 3 + .../src/ReactFiberUnwindWork.old.js | 3 + .../src/ReactFiberWorkLoop.new.js | 6 + .../src/ReactFiberWorkLoop.old.js | 6 + .../src/ReactTestSelectors.js | 35 +- .../react-reconciler/src/ReactWorkTags.js | 4 +- .../ReactFiberHostContext-test.internal.js | 4 + .../src/forks/ReactFiberHostConfig.custom.js | 16 +- .../src/getComponentNameFromFiber.js | 2 + packages/react-server/src/ReactFizzServer.js | 118 +- .../forks/ReactServerFormatConfig.custom.js | 16 + .../src/ReactTestHostConfig.js | 1 + .../src/ReactTestRenderer.js | 4 +- scripts/error-codes/codes.json | 8 +- scripts/shared/inlinedHostConfigs.js | 7 +- 52 files changed, 7031 insertions(+), 525 deletions(-) create mode 100644 packages/react-dom-bindings/src/client/ReactDOMFloatClient.js create mode 100644 packages/react-dom-bindings/src/server/ReactDOMFloatServer.js create mode 100644 packages/react-dom-bindings/src/shared/ReactDOMDispatcher.js create mode 100644 packages/react-dom-bindings/src/shared/ReactDOMFloat.js create mode 100644 packages/react-dom-bindings/src/shared/ReactDOMResourceValidation.js create mode 100644 packages/react-dom/src/__tests__/ReactDOMFloat-test.js create mode 100644 packages/react-reconciler/src/ReactFiberHostConfigWithNoResources.js diff --git a/packages/react-art/src/ReactARTHostConfig.js b/packages/react-art/src/ReactARTHostConfig.js index dbaba801b185a..9bf674bfc64ab 100644 --- a/packages/react-art/src/ReactARTHostConfig.js +++ b/packages/react-art/src/ReactARTHostConfig.js @@ -243,6 +243,7 @@ export * from 'react-reconciler/src/ReactFiberHostConfigWithNoHydration'; export * from 'react-reconciler/src/ReactFiberHostConfigWithNoScopes'; export * from 'react-reconciler/src/ReactFiberHostConfigWithNoTestSelectors'; export * from 'react-reconciler/src/ReactFiberHostConfigWithNoMicrotasks'; +export * from 'react-reconciler/src/ReactFiberHostConfigWithNoResources'; export function appendInitialChild(parentInstance, child) { if (typeof child === 'string') { diff --git a/packages/react-dom-bindings/src/client/ReactDOMComponentTree.js b/packages/react-dom-bindings/src/client/ReactDOMComponentTree.js index 58e5d72acd581..87529594664f2 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMComponentTree.js +++ b/packages/react-dom-bindings/src/client/ReactDOMComponentTree.js @@ -23,6 +23,7 @@ import type { import { HostComponent, + HostResource, HostText, HostRoot, SuspenseComponent, @@ -30,7 +31,7 @@ import { import {getParentSuspenseInstance} from './ReactDOMHostConfig'; -import {enableScopeAPI} from 'shared/ReactFeatureFlags'; +import {enableScopeAPI, enableFloat} from 'shared/ReactFeatureFlags'; const randomKey = Math.random() .toString(36) @@ -166,7 +167,8 @@ export function getInstanceFromNode(node: Node): Fiber | null { inst.tag === HostComponent || inst.tag === HostText || inst.tag === SuspenseComponent || - inst.tag === HostRoot + inst.tag === HostRoot || + (enableFloat ? inst.tag === HostResource : false) ) { return inst; } else { @@ -181,7 +183,11 @@ export function getInstanceFromNode(node: Node): Fiber | null { * DOM node. */ export function getNodeFromInstance(inst: Fiber): Instance | TextInstance { - if (inst.tag === HostComponent || inst.tag === HostText) { + if ( + inst.tag === HostComponent || + inst.tag === HostText || + (enableFloat ? inst.tag === HostResource : false) + ) { // In Fiber this, is just the state node right now. We assume it will be // a host component or host text. return inst.stateNode; diff --git a/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js b/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js new file mode 100644 index 0000000000000..ebe0f72c3cb9c --- /dev/null +++ b/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js @@ -0,0 +1,825 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 {Instance} from './ReactDOMHostConfig'; +import ReactDOMSharedInternals from 'shared/ReactDOMSharedInternals.js'; +import { + validateUnmatchedLinkResourceProps, + validatePreloadResourceDifference, + validateHrefKeyedUpdatedProps, + validateStyleResourceDifference, + validateLinkPropsForStyleResource, + validateLinkPropsForPreloadResource, + validatePreloadArguments, + validatePreinitArguments, +} from '../shared/ReactDOMResourceValidation'; +import {createElement, setInitialProperties} from './ReactDOMComponent'; +import {HTML_NAMESPACE} from '../shared/DOMNamespaces'; + +// The resource types we support. currently they match the form for the as argument. +// In the future this may need to change, especially when modules / scripts are supported +type ResourceType = 'style' | 'font'; + +type PreloadProps = { + rel: 'preload', + as: ResourceType, + href: string, + [string]: mixed, +}; +type PreloadResource = { + type: 'preload', + href: string, + ownerDocument: Document, + props: PreloadProps, + instance: Element, +}; + +type StyleProps = { + rel: 'stylesheet', + href: string, + 'data-rprec': string, + [string]: mixed, +}; +type StyleResource = { + type: 'style', + + // Ref count for resource + count: number, + + // Resource Descriptors + href: string, + precedence: string, + props: StyleProps, + + // Related Resources + hint: ?PreloadResource, + + // Insertion + preloaded: boolean, + loaded: boolean, + error: mixed, + instance: ?Element, + ownerDocument: Document, +}; + +type Props = {[string]: mixed}; + +type Resource = StyleResource | PreloadResource; + +// Brief on purpose due to insertion by script when streaming late boundaries +// s = Status +// l = loaded +// e = errored +type StyleResourceLoadingState = Promise & {s?: 'l' | 'e'}; + +// When rendering we set the currentDocument if one exists. we use this for Resources +// we encounter during render. If this is null and we are dispatching preloads and +// other calls on the ReactDOM module we look for the window global and get the document from there +let currentDocument: ?Document = null; + +// It is valid to preload even when we aren't actively rendering. For cases where Float functions are +// called when there is no rendering we track the last used document. It is not safe to insert +// arbitrary resources into the lastCurrentDocument b/c it may not actually be the document +// that the resource is meant to apply too (for example stylesheets or scripts). This is only +// appropriate for resources that don't really have a strict tie to the document itself for example +// preloads +let lastCurrentDocument: ?Document = null; + +// When the document Node that hosts style resources is removed from the tree and another one created +// the style Resources end up in a detatched state. We need to be able to restore them to the newly +// inserted hosts (html, head, or body, preferring head). However to simplify the logic we attempt +// restoration anytime a new Resource host mounts but we only want to restore once per commit. This +// boolean is used to flag that a restore should happen or be ignored and resets on each render +let stylesRestorable = true; + +let previousDispatcher = null; +export function prepareToRenderResources(ownerDocument: Document) { + currentDocument = lastCurrentDocument = ownerDocument; + stylesRestorable = true; + previousDispatcher = ReactDOMSharedInternals.Dispatcher.current; + ReactDOMSharedInternals.Dispatcher.current = ReactDOMClientDispatcher; +} + +export function cleanupAfterRenderResources() { + currentDocument = null; + ReactDOMSharedInternals.Dispatcher.current = previousDispatcher; + previousDispatcher = null; +} + +const ReactDOMClientDispatcher = {preload, preinit}; + +// For client we set the dispatcher to the default client dispatcher. In mixed environments (like tests) +// this will be temporarily overwritten when another runtime is rendering. We do this so we can handle +// event callbacks with the client dispatcher. In the future we will likely have different client +// dispatchers for when we are in render mode vs non-render mode +ReactDOMSharedInternals.Dispatcher.current = ReactDOMClientDispatcher; + +// global maps of Resources +const preloadResources: Map = new Map(); +const styleResources: Map = new Map(); + +// 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 { + try { + return currentDocument || lastCurrentDocument || window.document; + } catch (error) { + return null; + } +} + +// -------------------------------------- +// ReactDOM.Preload +// -------------------------------------- +type PreloadAs = ResourceType; +type PreloadOptions = {as: PreloadAs, crossOrigin?: string}; +function preload(href: string, options: PreloadOptions) { + if (__DEV__) { + validatePreloadArguments(href, options); + } + const ownerDocument = getDocumentForPreloads(); + if ( + typeof href === 'string' && + href && + typeof options === 'object' && + options !== null && + ownerDocument + ) { + const as = options.as; + const resource = preloadResources.get(href); + if (resource) { + if (__DEV__) { + const originallyImplicit = + (resource: any)._dev_implicit_construction === true; + const latestProps = preloadPropsFromPreloadOptions(href, as, options); + validatePreloadResourceDifference( + resource.props, + originallyImplicit, + latestProps, + false, + ); + } + } else { + const resourceProps = preloadPropsFromPreloadOptions(href, as, options); + createPreloadResource(ownerDocument, href, resourceProps); + } + } +} + +function preloadPropsFromPreloadOptions( + href: string, + as: ResourceType, + options: PreloadOptions, +): PreloadProps { + return { + href, + rel: 'preload', + as, + crossOrigin: as === 'font' ? '' : options.crossOrigin, + }; +} + +// -------------------------------------- +// ReactDOM.preinit +// -------------------------------------- + +type PreinitAs = 'style'; +type PreinitOptions = { + as: PreinitAs, + crossOrigin?: string, + precedence?: string, +}; +function preinit(href: string, options: PreinitOptions) { + if (__DEV__) { + validatePreinitArguments(href, options); + } + + if ( + typeof href === 'string' && + href && + typeof options === 'object' && + options !== null + ) { + const as = options.as; + if (!currentDocument) { + // 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 preloadResource = preloadResources.get(href); + if (!preloadResource) { + const preloadProps = preloadPropsFromPreinitOptions( + href, + as, + options, + ); + createPreloadResource(preloadDocument, href, preloadProps); + } + } + return; + } + + switch (as) { + case 'style': { + const precedence = options.precedence || 'default'; + let resource = styleResources.get(href); + if (resource) { + if (__DEV__) { + const latestProps = stylePropsFromPreinitOptions( + href, + precedence, + options, + ); + validateStyleResourceDifference(resource.props, latestProps); + } + } else { + const resourceProps = stylePropsFromPreinitOptions( + href, + precedence, + options, + ); + resource = createStyleResource( + currentDocument, + href, + precedence, + resourceProps, + ); + } + acquireResource(resource); + } + } + } +} + +function preloadPropsFromPreinitOptions( + href: string, + as: ResourceType, + options: PreinitOptions, +): PreloadProps { + return { + href, + rel: 'preload', + as, + crossOrigin: as === 'font' ? '' : options.crossOrigin, + }; +} + +function stylePropsFromPreinitOptions( + href: string, + precedence: string, + options: PreinitOptions, +): StyleProps { + return { + rel: 'stylesheet', + href, + 'data-rprec': precedence, + crossOrigin: options.crossOrigin, + }; +} + +// -------------------------------------- +// Resources from render +// -------------------------------------- + +type StyleQualifyingProps = { + rel: 'stylesheet', + href: string, + precedence: string, + [string]: mixed, +}; +type PreloadQualifyingProps = { + rel: 'preload', + href: string, + as: ResourceType, + [string]: mixed, +}; + +// This function is called in complete work and we should always have a currentDocument set +export function getResource( + type: string, + pendingProps: Props, + currentProps: null | Props, +): null | Resource { + if (!currentDocument) { + throw new Error( + '"currentDocument" was expected to exist. This is a bug in React.', + ); + } + switch (type) { + case 'link': { + const {rel} = pendingProps; + switch (rel) { + case 'stylesheet': { + let didWarn; + if (__DEV__) { + if (currentProps) { + didWarn = validateHrefKeyedUpdatedProps( + pendingProps, + currentProps, + ); + } + if (!didWarn) { + didWarn = validateLinkPropsForStyleResource(pendingProps); + } + } + const {precedence, href} = pendingProps; + if (typeof href === 'string' && typeof precedence === 'string') { + // We've asserted all the specific types for StyleQualifyingProps + const styleRawProps: StyleQualifyingProps = (pendingProps: any); + + // We construct or get an existing resource for the style itself and return it + let resource = styleResources.get(href); + if (resource) { + if (__DEV__) { + if (!didWarn) { + const latestProps = stylePropsFromRawProps(styleRawProps); + if ((resource: any)._dev_preload_props) { + adoptPreloadProps( + latestProps, + (resource: any)._dev_preload_props, + ); + } + validateStyleResourceDifference(resource.props, latestProps); + } + } + } else { + const resourceProps = stylePropsFromRawProps(styleRawProps); + resource = createStyleResource( + currentDocument, + href, + precedence, + resourceProps, + ); + immediatelyPreloadStyleResource(resource); + } + return resource; + } + return null; + } + case 'preload': { + if (__DEV__) { + validateLinkPropsForPreloadResource(pendingProps); + } + const {href, as} = pendingProps; + if (typeof href === 'string' && isResourceAsType(as)) { + // We've asserted all the specific types for PreloadQualifyingProps + const preloadRawProps: PreloadQualifyingProps = (pendingProps: any); + let resource = preloadResources.get(href); + if (resource) { + if (__DEV__) { + const originallyImplicit = + (resource: any)._dev_implicit_construction === true; + const latestProps = preloadPropsFromRawProps(preloadRawProps); + validatePreloadResourceDifference( + resource.props, + originallyImplicit, + latestProps, + false, + ); + } + } else { + const resourceProps = preloadPropsFromRawProps(preloadRawProps); + resource = createPreloadResource( + currentDocument, + href, + resourceProps, + ); + } + return resource; + } + return null; + } + default: { + if (__DEV__) { + validateUnmatchedLinkResourceProps(pendingProps, currentProps); + } + return null; + } + } + } + default: { + throw new Error( + `getResource encountered a resource type it did not expect: "${type}". this is a bug in React.`, + ); + } + } +} + +function preloadPropsFromRawProps( + rawBorrowedProps: PreloadQualifyingProps, +): PreloadProps { + return Object.assign({}, rawBorrowedProps); +} + +function stylePropsFromRawProps(rawProps: StyleQualifyingProps): StyleProps { + const props: StyleProps = Object.assign({}, rawProps); + props['data-rprec'] = rawProps.precedence; + props.precedence = null; + + return props; +} + +// -------------------------------------- +// Resource Reconciliation +// -------------------------------------- + +export function acquireResource(resource: Resource): Instance { + switch (resource.type) { + case 'style': { + return acquireStyleResource(resource); + } + case 'preload': { + return resource.instance; + } + default: { + throw new Error( + `acquireResource encountered a resource type it did not expect: "${resource.type}". this is a bug in React.`, + ); + } + } +} + +export function releaseResource(resource: Resource) { + switch (resource.type) { + case 'style': { + resource.count--; + } + } +} + +function createResourceInstance( + type: string, + props: Object, + ownerDocument: Document, +): Instance { + const element = createElement(type, props, ownerDocument, HTML_NAMESPACE); + setInitialProperties(element, type, props); + return element; +} + +function createStyleResource( + ownerDocument: Document, + href: string, + precedence: string, + props: StyleProps, +): StyleResource { + if (__DEV__) { + if (styleResources.has(href)) { + console.error( + 'createStyleResource was called when a style Resource matching the same href already exists. This is a bug in React.', + ); + } + } + + const existingEl = ownerDocument.querySelector( + `link[rel="stylesheet"][href="${href}"]`, + ); + const resource = { + type: 'style', + count: 0, + href, + precedence, + props, + hint: null, + preloaded: false, + loaded: false, + error: false, + ownerDocument, + instance: existingEl, + }; + styleResources.set(href, resource); + + if (existingEl) { + // If we have an existing element in the DOM we don't need to preload this resource nor can we + // adopt props from any preload that might exist already for this resource. We do need to try + // to reify the Resource loading state the best we can. + const loadingState: ?StyleResourceLoadingState = (existingEl: any)._p; + if (loadingState) { + switch (loadingState.s) { + case 'l': { + resource.loaded = true; + break; + } + case 'e': { + resource.error = true; + break; + } + default: { + attachLoadListeners(existingEl, resource); + } + } + } else { + // This is unfortunately just an assumption. The rationale here is that stylesheets without + // a loading state must have been flushed in the shell and would have blocked until loading + // or error. we can't know afterwards which happened for all types of stylesheets (cross origin) + // for instance) and the techniques for determining if a sheet has loaded that we do have still + // fail if the sheet loaded zero rules. At the moment we are going to just opt to assume the + // sheet is loaded if it was flushed in the shell + resource.loaded = true; + } + } else { + const hint = preloadResources.get(href); + if (hint) { + resource.hint = hint; + // If a preload for this style Resource already exists there are certain props we want to adopt + // on the style Resource, primarily focussed on making sure the style network pathways utilize + // the preload pathways. For instance if you have diffreent crossOrigin attributes for a preload + // and a stylesheet the stylesheet will make a new request even if the preload had already loaded + const preloadProps = hint.props; + adoptPreloadProps(resource.props, hint.props); + if (__DEV__) { + (resource: any)._dev_preload_props = preloadProps; + } + } + } + + return resource; +} + +function adoptPreloadProps( + styleProps: StyleProps, + preloadProps: PreloadProps, +): void { + if (styleProps.crossOrigin == null) + styleProps.crossOrigin = preloadProps.crossOrigin; + if (styleProps.referrerPolicy == null) + styleProps.referrerPolicy = preloadProps.referrerPolicy; + if (styleProps.media == null) styleProps.media = preloadProps.media; + if (styleProps.title == null) styleProps.title = preloadProps.title; +} + +function immediatelyPreloadStyleResource(resource: StyleResource) { + // This function must be called synchronously after creating a styleResource otherwise it may + // violate assumptions around the existence of a preload. The reason it is extracted out is we + // don't always want to preload a style, in particular when we are going to synchronously insert + // that style. We confirm the style resource has no preload already and then construct it. If + // we wait and call this later it is possible a preload will already exist for this href + if (resource.instance === null && resource.hint === null) { + const {href, props} = resource; + const preloadProps = preloadPropsFromStyleProps(props); + resource.hint = createPreloadResource( + resource.ownerDocument, + href, + preloadProps, + ); + } +} + +function preloadPropsFromStyleProps(props: StyleProps): PreloadProps { + return { + rel: 'preload', + as: 'style', + href: props.href, + crossOrigin: props.crossOrigin, + integrity: props.integrity, + media: props.media, + hrefLang: props.hrefLang, + referrerPolicy: props.referrerPolicy, + }; +} + +function createPreloadResource( + ownerDocument: Document, + href: string, + props: PreloadProps, +): PreloadResource { + let element = ownerDocument.querySelector( + `link[rel="preload"][href="${href}"]`, + ); + if (!element) { + element = createResourceInstance('link', props, ownerDocument); + insertPreloadInstance(element, ownerDocument); + } + return { + type: 'preload', + href: href, + ownerDocument, + props, + instance: element, + }; +} + +function acquireStyleResource(resource: StyleResource): Instance { + if (!resource.instance) { + const {props, ownerDocument, precedence} = resource; + const existingEl = ownerDocument.querySelector( + `link[rel="stylesheet"][data-rprec][href="${props.href}"]`, + ); + if (existingEl) { + resource.instance = existingEl; + resource.preloaded = true; + const loadingState: ?StyleResourceLoadingState = (existingEl: any)._p; + if (loadingState) { + // if an existingEl is found there should always be a loadingState because if + // the resource was flushed in the head it should have already been found when + // the resource was first created. Still defensively we gate this + switch (loadingState.s) { + case 'l': { + resource.loaded = true; + resource.error = false; + break; + } + case 'e': { + resource.error = true; + break; + } + default: { + attachLoadListeners(existingEl, resource); + } + } + } else { + resource.loaded = true; + } + } else { + const instance = createResourceInstance( + 'link', + resource.props, + ownerDocument, + ); + + attachLoadListeners(instance, resource); + insertStyleInstance(instance, precedence, ownerDocument); + resource.instance = instance; + } + } + resource.count++; + return resource.instance; +} + +function attachLoadListeners(instance: Instance, resource: StyleResource) { + const listeners = {}; + listeners.load = onResourceLoad.bind( + null, + instance, + resource, + listeners, + loadAndErrorEventListenerOptions, + ); + listeners.error = onResourceError.bind( + null, + instance, + resource, + listeners, + loadAndErrorEventListenerOptions, + ); + + instance.addEventListener( + 'load', + listeners.load, + loadAndErrorEventListenerOptions, + ); + instance.addEventListener( + 'error', + listeners.error, + loadAndErrorEventListenerOptions, + ); +} + +const loadAndErrorEventListenerOptions = { + passive: true, +}; + +function onResourceLoad( + instance: Instance, + resource: StyleResource, + listeners: {[string]: () => mixed}, + listenerOptions: typeof loadAndErrorEventListenerOptions, +) { + resource.loaded = true; + resource.error = false; + for (const event in listeners) { + instance.removeEventListener(event, listeners[event], listenerOptions); + } +} + +function onResourceError( + instance: Instance, + resource: StyleResource, + listeners: {[string]: () => mixed}, + listenerOptions: typeof loadAndErrorEventListenerOptions, +) { + resource.loaded = false; + resource.error = true; + for (const event in listeners) { + instance.removeEventListener(event, listeners[event], listenerOptions); + } +} + +function insertStyleInstance( + instance: Instance, + precedence: string, + ownerDocument: Document, +): void { + const nodes = ownerDocument.querySelectorAll( + 'link[rel="stylesheet"][data-rprec]', + ); + const last = nodes.length ? nodes[nodes.length - 1] : null; + let prior = last; + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + const nodePrecedence = node.dataset.rprec; + if (nodePrecedence === precedence) { + prior = node; + } else if (prior !== last) { + break; + } + } + if (prior) { + // We get the prior from the document so we know it is in the tree. + // We also know that links can't be the topmost Node so the parentNode + // must exist. + ((prior.parentNode: any): Node).insertBefore(instance, prior.nextSibling); + } else { + const parent = ownerDocument.head; + if (parent) { + parent.insertBefore(instance, parent.firstChild); + } else { + throw new Error( + 'While attempting to insert a Resource, React expected the Document to contain' + + ' a head element but it was not found.', + ); + } + } +} + +export function restoreAllStylesResources() { + if (stylesRestorable) { + stylesRestorable = false; + const iter = styleResources.values(); + let resource; + while ((resource = iter.next().value)) { + const {instance, count, ownerDocument, precedence} = resource; + if (count && instance && !ownerDocument.contains(instance)) { + insertStyleInstance(instance, precedence, ownerDocument); + } + } + } +} + +function insertPreloadInstance( + instance: Instance, + ownerDocument: Document, +): void { + if (!ownerDocument.contains(instance)) { + const parent = ownerDocument.head; + if (parent) { + parent.appendChild(instance); + } else { + throw new Error( + 'While attempting to insert a Resource, React expected the Document to contain' + + ' a head element but it was not found.', + ); + } + } +} + +export function isHostResourceType(type: string, props: Props): boolean { + switch (type) { + case 'link': { + switch (props.rel) { + case 'stylesheet': { + if (__DEV__) { + validateLinkPropsForStyleResource(props); + } + const {href, precedence, onLoad, onError, disabled} = props; + return ( + typeof href === 'string' && + typeof precedence === 'string' && + !onLoad && + !onError && + disabled == null + ); + } + case 'preload': { + if (__DEV__) { + validateLinkPropsForStyleResource(props); + } + const {href, as, onLoad, onError} = props; + return ( + !onLoad && + !onError && + typeof href === 'string' && + isResourceAsType(as) + ); + } + } + } + } + return false; +} + +function isResourceAsType(as: mixed): boolean { + return as === 'style' || as === 'font'; +} diff --git a/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js b/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js index 64195ac7f5957..c62f0e7f49473 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js @@ -67,7 +67,11 @@ import { enableScopeAPI, enableFloat, } from 'shared/ReactFeatureFlags'; -import {HostComponent, HostText} from 'react-reconciler/src/ReactWorkTags'; +import { + HostComponent, + HostResource, + HostText, +} from 'react-reconciler/src/ReactWorkTags'; import {listenToAllSupportedEvents} from '../events/DOMPluginEventSystem'; import {DefaultEventPriority} from 'react-reconciler/src/ReactEventPriorities'; @@ -75,6 +79,13 @@ import {DefaultEventPriority} from 'react-reconciler/src/ReactEventPriorities'; // TODO: Remove this deep import when we delete the legacy root API import {ConcurrentMode, NoMode} from 'react-reconciler/src/ReactTypeOfMode'; +import { + prepareToRenderResources, + cleanupAfterRenderResources, + isHostResourceType, + restoreAllStylesResources, +} from './ReactDOMFloatClient'; + export type Type = string; export type Props = { autoFocus?: boolean, @@ -301,6 +312,14 @@ export function finalizeInitialChildren( return !!props.autoFocus; case 'img': return true; + case 'html': + case 'head': + case 'body': { + if (enableFloat) { + return true; + } + } + // eslint-disable-next-line-no-fallthrough default: return false; } @@ -439,6 +458,13 @@ export function commitMount( } return; } + case 'html': + case 'head': + case 'body': { + if (enableFloat) { + restoreAllStylesResources(); + } + } } } @@ -680,14 +706,6 @@ export function clearContainer(container: Container): void { export const supportsHydration = true; -export function isHydratableResource(type: string, props: Props): boolean { - return ( - type === 'link' && - typeof (props: any).precedence === 'string' && - (props: any).rel === 'stylesheet' - ); -} - export function canHydrateInstance( instance: HydratableInstance, type: string, @@ -761,18 +779,6 @@ export function getSuspenseInstanceFallbackErrorDetails( digest, }; } - - // let value = {message: undefined, hash: undefined}; - // const nextSibling = instance.nextSibling; - // if (nextSibling) { - // const dataset = ((nextSibling: any): HTMLTemplateElement).dataset; - // value.message = dataset.msg; - // value.hash = dataset.hash; - // if (__DEV__) { - // value.stack = dataset.stack; - // } - // } - // return value; } export function registerSuspenseInstanceRetry( @@ -788,10 +794,7 @@ function getNextHydratable(node) { const nodeType = node.nodeType; if (enableFloat) { if (nodeType === ELEMENT_NODE) { - if ( - ((node: any): Element).tagName === 'LINK' && - ((node: any): Element).hasAttribute('data-rprec') - ) { + if (isHostResourceInstance(((node: any): Element))) { continue; } break; @@ -903,43 +906,6 @@ export function hydrateSuspenseInstance( precacheFiberNode(internalInstanceHandle, suspenseInstance); } -export function getMatchingResourceInstance( - type: string, - props: Props, - rootHostContainer: Container, -): ?Instance { - if (enableFloat) { - switch (type) { - case 'link': { - if (typeof (props: any).href !== 'string') { - return null; - } - const selector = `link[rel="stylesheet"][data-rprec][href="${ - (props: any).href - }"]`; - const link = getOwnerDocumentFromRootContainer( - rootHostContainer, - ).querySelector(selector); - if (__DEV__) { - const allLinks = getOwnerDocumentFromRootContainer( - rootHostContainer, - ).querySelectorAll(selector); - if (allLinks.length > 1) { - console.error( - 'Stylesheet resources need a unique representation in the DOM while hydrating' + - ' and more than one matching DOM Node was found. To fix, ensure you are only' + - ' rendering one stylesheet link with an href attribute of "%s".', - (props: any).href, - ); - } - } - return link; - } - } - } - return null; -} - export function getNextHydratableInstanceAfterSuspenseInstance( suspenseInstance: SuspenseInstance, ): null | HydratableInstance { @@ -1281,6 +1247,7 @@ export function matchAccessibilityRole(node: Instance, role: string): boolean { export function getTextContent(fiber: Fiber): string | null { switch (fiber.tag) { + case HostResource: case HostComponent: let textContent = ''; const childNodes = fiber.stateNode.childNodes; @@ -1390,3 +1357,47 @@ export function requestPostPaintCallback(callback: (time: number) => void) { localRequestAnimationFrame(time => callback(time)); }); } +// ------------------- +// Resources +// ------------------- + +export const supportsResources = true; + +export {isHostResourceType}; +export function isHostResourceInstance( + instance: Instance | Container, +): boolean { + if (instance.nodeType === ELEMENT_NODE) { + switch (instance.tagName.toLowerCase()) { + case 'link': { + const rel = ((instance: any): HTMLLinkElement).rel; + return ( + rel === 'preload' || + (rel === 'stylesheet' && instance.hasAttribute('data-rprec')) + ); + } + default: { + return false; + } + } + } + return false; +} + +export function prepareRendererToRender(rootContainer: Container) { + if (enableFloat) { + prepareToRenderResources(getOwnerDocumentFromRootContainer(rootContainer)); + } +} + +export function resetRendererAfterRender() { + if (enableFloat) { + cleanupAfterRenderResources(); + } +} + +export { + getResource, + acquireResource, + releaseResource, +} from './ReactDOMFloatClient'; diff --git a/packages/react-dom-bindings/src/events/DOMPluginEventSystem.js b/packages/react-dom-bindings/src/events/DOMPluginEventSystem.js index 0b1f1c498e024..06c42b82e2252 100644 --- a/packages/react-dom-bindings/src/events/DOMPluginEventSystem.js +++ b/packages/react-dom-bindings/src/events/DOMPluginEventSystem.js @@ -33,6 +33,7 @@ import { HostRoot, HostPortal, HostComponent, + HostResource, HostText, ScopeComponent, } from 'react-reconciler/src/ReactWorkTags'; @@ -52,6 +53,7 @@ import { enableLegacyFBSupport, enableCreateEventHandleAPI, enableScopeAPI, + enableFloat, } from 'shared/ReactFeatureFlags'; import { invokeGuardedCallbackAndCatchFirstError, @@ -621,7 +623,11 @@ export function dispatchEventForPluginEventSystem( return; } const parentTag = parentNode.tag; - if (parentTag === HostComponent || parentTag === HostText) { + if ( + parentTag === HostComponent || + parentTag === HostText || + (enableFloat ? parentTag === HostResource : false) + ) { node = ancestorInst = parentNode; continue mainLoop; } @@ -675,7 +681,10 @@ export function accumulateSinglePhaseListeners( while (instance !== null) { const {stateNode, tag} = instance; // Handle listeners that are on HostComponents (i.e.
) - if (tag === HostComponent && stateNode !== null) { + if ( + (tag === HostComponent || (enableFloat ? tag === HostResource : false)) && + stateNode !== null + ) { lastHostComponent = stateNode; // createEventHandle listeners @@ -786,7 +795,10 @@ export function accumulateTwoPhaseListeners( while (instance !== null) { const {stateNode, tag} = instance; // Handle listeners that are on HostComponents (i.e.
) - if (tag === HostComponent && stateNode !== null) { + if ( + (tag === HostComponent || (enableFloat ? tag === HostResource : false)) && + stateNode !== null + ) { const currentTarget = stateNode; const captureListener = getListener(instance, captureName); if (captureListener != null) { @@ -883,7 +895,10 @@ function accumulateEnterLeaveListenersForEvent( if (alternate !== null && alternate === common) { break; } - if (tag === HostComponent && stateNode !== null) { + if ( + (tag === HostComponent || (enableFloat ? tag === HostResource : false)) && + stateNode !== null + ) { const currentTarget = stateNode; if (inCapturePhase) { const captureListener = getListener(instance, registrationName); diff --git a/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js b/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js new file mode 100644 index 0000000000000..eead934e95d52 --- /dev/null +++ b/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js @@ -0,0 +1,580 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 ReactDOMSharedInternals from 'shared/ReactDOMSharedInternals'; +import { + validatePreloadResourceDifference, + validateStyleResourceDifference, + validateStyleAndHintProps, + validateLinkPropsForStyleResource, + validateLinkPropsForPreloadResource, + validatePreloadArguments, + validatePreinitArguments, +} from '../shared/ReactDOMResourceValidation'; + +type Props = {[string]: mixed}; + +type ResourceType = 'style' | 'font'; + +type PreloadProps = { + rel: 'preload', + as: ResourceType, + href: string, + [string]: mixed, +}; +type PreloadResource = { + type: 'preload', + as: ResourceType, + href: string, + props: PreloadProps, + flushed: boolean, +}; + +type StyleProps = { + rel: 'stylesheet', + href: string, + 'data-rprec': string, + [string]: mixed, +}; +type StyleResource = { + type: 'style', + href: string, + precedence: string, + props: StyleProps, + + flushed: boolean, + inShell: boolean, // flushedInShell + hint: PreloadResource, +}; + +export type Resource = PreloadResource | StyleResource; + +export type Resources = { + // Request local cache + preloadsMap: Map, + stylesMap: Map, + + // Flushing queues for Resource dependencies + explicitPreloads: Set, + implicitPreloads: Set, + precedences: Map>, + + // Module-global-like reference for current boundary resources + boundaryResources: ?BoundaryResources, +}; + +export function createResources(): Resources { + return { + // persistent + preloadsMap: new Map(), // preloadResources + stylesMap: new Map(), // styleResources + + // cleared on flush + explicitPreloads: new Set(), // explicitPreloads + implicitPreloads: new Set(), // add bootstrap script to implicit preloads + precedences: new Map(), + + // like a module global for currently rendering boundary + boundaryResources: null, + }; +} + +export type BoundaryResources = Set; + +export function createBoundaryResources(): BoundaryResources { + return new Set(); +} + +export function mergeBoundaryResources( + target: BoundaryResources, + source: BoundaryResources, +) { + source.forEach(resource => target.add(resource)); +} + +let currentResources: ?Resources = null; + +let previousDispatcher = null; +export function prepareToRender(resources: Resources) { + currentResources = resources; + + previousDispatcher = ReactDOMSharedInternals.Dispatcher.current; + ReactDOMSharedInternals.Dispatcher.current = Dispatcher; +} + +export function setCurrentlyRenderingBoundaryResources( + resources: Resources, + boundaryResources: ?BoundaryResources, +) { + resources.boundaryResources = boundaryResources; +} + +export function cleanupAfterRender() { + currentResources = null; + + ReactDOMSharedInternals.Dispatcher.current = previousDispatcher; + previousDispatcher = null; +} + +type PreloadAs = ResourceType; +type PreloadOptions = {as: PreloadAs, crossOrigin?: string}; +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. + return; + } + if (__DEV__) { + validatePreloadArguments(href, options); + } + if ( + typeof href === 'string' && + href && + typeof options === 'object' && + options !== null + ) { + const as = options.as; + let resource = currentResources.preloadsMap.get(href); + if (resource) { + if (__DEV__) { + const originallyImplicit = + (resource: any)._dev_implicit_construction === true; + const latestProps = preloadPropsFromPreloadOptions(href, as, options); + validatePreloadResourceDifference( + resource.props, + originallyImplicit, + latestProps, + false, + ); + } + } else { + resource = createPreloadResource( + currentResources, + href, + as, + preloadPropsFromPreloadOptions(href, as, options), + ); + } + captureExplicitPreloadResourceDependency(currentResources, resource); + } +} + +type PreinitAs = 'style'; +type PreinitOptions = { + as: PreinitAs, + precedence?: string, + crossOrigin?: string, +}; +function preinit(href: string, options: PreinitOptions) { + 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. + return; + } + if (__DEV__) { + validatePreinitArguments(href, options); + } + if ( + typeof href === 'string' && + href && + typeof options === 'object' && + options !== null + ) { + const as = options.as; + switch (as) { + case 'style': { + const precedence = options.precedence || 'default'; + + let resource = currentResources.stylesMap.get(href); + if (resource) { + if (__DEV__) { + const latestProps = stylePropsFromPreinitOptions( + href, + precedence, + options, + ); + validateStyleResourceDifference(resource.props, latestProps); + } + } else { + const resourceProps = stylePropsFromPreinitOptions( + href, + precedence, + options, + ); + resource = createStyleResource( + currentResources, + href, + precedence, + resourceProps, + ); + } + + // Do not associate preinit style resources with any specific boundary regardless of where it is called + captureStyleResourceDependency(currentResources, null, resource); + + return; + } + } + } +} + +function preloadPropsFromPreloadOptions( + href: string, + as: ResourceType, + options: PreloadOptions, +): PreloadProps { + return { + href, + rel: 'preload', + as, + crossOrigin: as === 'font' ? '' : options.crossOrigin, + }; +} + +function preloadPropsFromRawProps( + href: string, + as: ResourceType, + rawProps: Props, +): PreloadProps { + const props: PreloadProps = Object.assign({}, rawProps); + props.href = href; + props.rel = 'preload'; + props.as = as; + if (as === 'font') { + // Font preloads always need CORS anonymous mode so we set it here + // regardless of the props provided. This should warn elsewhere in + // dev + props.crossOrigin = ''; + } + return props; +} + +function preloadAsStylePropsFromProps( + href: string, + props: Props | StyleProps, +): PreloadProps { + return { + rel: 'preload', + as: 'style', + href: href, + crossOrigin: props.crossOrigin, + integrity: props.integrity, + media: props.media, + hrefLang: props.hrefLang, + referrerPolicy: props.referrerPolicy, + }; +} + +function createPreloadResource( + resources: Resources, + href: string, + as: ResourceType, + props: PreloadProps, +): PreloadResource { + const {preloadsMap} = resources; + if (__DEV__) { + if (preloadsMap.has(href)) { + console.error( + 'createPreloadResource was called when a preload Resource matching the same href already exists. This is a bug in React.', + ); + } + } + + const resource = { + type: 'preload', + as, + href, + flushed: false, + props, + }; + preloadsMap.set(href, resource); + return resource; +} + +function stylePropsFromRawProps( + href: string, + precedence: string, + rawProps: Props, +): StyleProps { + const props: StyleProps = Object.assign({}, rawProps); + props.href = href; + props.rel = 'stylesheet'; + props['data-rprec'] = precedence; + delete props.precedence; + + return props; +} + +function stylePropsFromPreinitOptions( + href: string, + precedence: string, + options: PreinitOptions, +): StyleProps { + return { + rel: 'stylesheet', + href, + 'data-rprec': precedence, + crossOrigin: options.crossOrigin, + }; +} + +function createStyleResource( + resources: Resources, + href: string, + precedence: string, + props: StyleProps, +): StyleResource { + if (__DEV__) { + if (resources.stylesMap.has(href)) { + console.error( + 'createStyleResource was called when a style Resource matching the same href already exists. This is a bug in React.', + ); + } + } + const {stylesMap, preloadsMap} = resources; + + let hint = preloadsMap.get(href); + if (hint) { + // If a preload for this style Resource already exists there are certain props we want to adopt + // on the style Resource, primarily focussed on making sure the style network pathways utilize + // the preload pathways. For instance if you have diffreent crossOrigin attributes for a preload + // and a stylesheet the stylesheet will make a new request even if the preload had already loaded + const preloadProps = hint.props; + if (props.crossOrigin == null) props.crossOrigin = preloadProps.crossOrigin; + if (props.referrerPolicy == null) + props.referrerPolicy = preloadProps.referrerPolicy; + if (props.media == null) props.media = preloadProps.media; + if (props.title == null) props.title = preloadProps.title; + + if (__DEV__) { + validateStyleAndHintProps( + preloadProps, + props, + (hint: any)._dev_implicit_construction, + ); + } + } else { + const preloadResourceProps = preloadAsStylePropsFromProps(href, props); + hint = createPreloadResource( + resources, + href, + 'style', + preloadResourceProps, + ); + if (__DEV__) { + (hint: any)._dev_implicit_construction = true; + } + captureImplicitPreloadResourceDependency(resources, hint); + } + + const resource = { + type: 'style', + href, + precedence, + flushed: false, + inShell: false, + props, + hint, + }; + stylesMap.set(href, resource); + + return resource; +} + +function captureStyleResourceDependency( + resources: Resources, + boundaryResources: ?BoundaryResources, + styleResource: StyleResource, +): void { + const {precedences} = resources; + const {precedence} = styleResource; + + if (boundaryResources) { + boundaryResources.add(styleResource); + if (!precedences.has(precedence)) { + precedences.set(precedence, new Set()); + } + } else { + let set = precedences.get(precedence); + if (!set) { + set = new Set(); + precedences.set(precedence, set); + } + set.add(styleResource); + } +} + +function captureExplicitPreloadResourceDependency( + resources: Resources, + preloadResource: PreloadResource, +): void { + resources.explicitPreloads.add(preloadResource); +} + +function captureImplicitPreloadResourceDependency( + resources: Resources, + preloadResource: PreloadResource, +): void { + resources.implicitPreloads.add(preloadResource); +} + +// Construct a resource from link props. +export function resourcesFromLink(props: Props): boolean { + if (!currentResources) { + throw new Error( + '"currentResources" was expected to exist. This is a bug in React.', + ); + } + const {rel, href} = props; + if (!href || typeof href !== 'string') { + return false; + } + + switch (rel) { + case 'stylesheet': { + const {onLoad, onError, precedence, disabled} = props; + if ( + typeof precedence !== 'string' || + onLoad || + onError || + disabled != null + ) { + // This stylesheet is either not opted into Resource semantics or has conflicting properties which + // disqualify it for such. We can still create a preload resource to help it load faster on the + // client + if (__DEV__) { + validateLinkPropsForStyleResource(props); + } + let preloadResource = currentResources.preloadsMap.get(href); + if (!preloadResource) { + preloadResource = createPreloadResource( + currentResources, + href, + 'style', + preloadAsStylePropsFromProps(href, props), + ); + if (__DEV__) { + (preloadResource: any)._dev_implicit_construction = true; + } + } + captureImplicitPreloadResourceDependency( + currentResources, + preloadResource, + ); + return false; + } else { + // We are able to convert this link element to a resource exclusively. We construct the relevant Resource + // and return true indicating that this link was fully consumed. + let resource = currentResources.stylesMap.get(href); + if (resource) { + if (__DEV__) { + const resourceProps = stylePropsFromRawProps( + href, + precedence, + props, + ); + validateStyleResourceDifference(resource.props, resourceProps); + } + } else { + const resourceProps = stylePropsFromRawProps(href, precedence, props); + resource = createStyleResource( + currentResources, + href, + precedence, + resourceProps, + ); + } + captureStyleResourceDependency( + currentResources, + currentResources.boundaryResources, + resource, + ); + return true; + } + } + case 'preload': { + const {as, onLoad, onError} = props; + if (onLoad || onError) { + // these props signal an opt-out of Resource semantics. We don't warn because there is no + // conflicting opt-in like there is with Style Resources + return false; + } + switch (as) { + case 'style': + case 'font': { + if (__DEV__) { + validateLinkPropsForPreloadResource(props); + } + let resource = currentResources.preloadsMap.get(href); + if (resource) { + if (__DEV__) { + const originallyImplicit = + (resource: any)._dev_implicit_construction === true; + const latestProps = preloadPropsFromRawProps(href, as, props); + validatePreloadResourceDifference( + resource.props, + originallyImplicit, + latestProps, + false, + ); + } + } else { + resource = createPreloadResource( + currentResources, + href, + as, + preloadPropsFromRawProps(href, as, props), + ); + } + captureExplicitPreloadResourceDependency(currentResources, resource); + return true; + } + } + return false; + } + } + return false; +} + +export function hoistResources( + resources: Resources, + source: BoundaryResources, +): void { + if (resources.boundaryResources) { + mergeBoundaryResources(resources.boundaryResources, source); + source.clear(); + } +} + +export function hoistResourcesToRoot( + resources: Resources, + boundaryResources: BoundaryResources, +): void { + boundaryResources.forEach(resource => { + // all precedences are set upon discovery. so we know we will have a set here + const set: Set = (resources.precedences.get( + resource.precedence, + ): any); + set.add(resource); + }); + boundaryResources.clear(); +} + +const Dispatcher = { + preload, + preinit, +}; diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js index c30d9673265c2..6f4ba0791785f 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js @@ -8,6 +8,8 @@ */ import type {ReactNodeList} from 'shared/ReactTypes'; +import type {Resources, BoundaryResources} from './ReactDOMFloatServer'; +export type {Resources, BoundaryResources}; import { checkHtmlStringCoercion, @@ -58,6 +60,19 @@ import hasOwnProperty from 'shared/hasOwnProperty'; import sanitizeURL from '../shared/sanitizeURL'; import isArray from 'shared/isArray'; +import { + prepareToRender as prepareToRenderImpl, + cleanupAfterRender as cleanupAfterRenderImpl, + resourcesFromLink, +} from './ReactDOMFloatServer'; +export { + createResources, + createBoundaryResources, + setCurrentlyRenderingBoundaryResources, + hoistResources, + hoistResourcesToRoot, +} from './ReactDOMFloatServer'; + // 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; @@ -73,7 +88,8 @@ export type ResponseState = { nextSuspenseID: number, sentCompleteSegmentFunction: boolean, sentCompleteBoundaryFunction: boolean, - sentClientRenderFunction: boolean, // We allow the legacy renderer to extend this object. + sentClientRenderFunction: boolean, + sentStyleInsertionFunction: boolean, // We allow the legacy renderer to extend this object. ... }; @@ -183,6 +199,7 @@ export function createResponseState( sentCompleteSegmentFunction: false, sentCompleteBoundaryFunction: false, sentClientRenderFunction: false, + sentStyleInsertionFunction: false, }; } @@ -206,6 +223,7 @@ type InsertionMode = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7; export type FormatContext = { insertionMode: InsertionMode, // root/svg/html/mathml/table selectedValue: null | string | Array, // the selected value(s) inside a + preambleOpen: boolean, }; function createFormatContext( @@ -215,6 +233,7 @@ function createFormatContext( return { insertionMode, selectedValue, + preambleOpen: true, }; } @@ -264,6 +283,25 @@ export function getChildFormatContext( return createFormatContext(HTML_MODE, null); } if (parentContext.insertionMode === ROOT_HTML_MODE) { + switch (type) { + case 'html': { + return parentContext; + } + case 'head': + case 'title': + case 'base': + case 'link': + case 'style': + case 'meta': + case 'script': + case 'noscript': + case 'template': { + break; + } + default: { + parentContext.preambleOpen = false; + } + } // We've emitted the root and is now in plain HTML mode. return createFormatContext(HTML_MODE, null); } @@ -1088,6 +1126,26 @@ function pushLink( target: Array, props: Object, responseState: ResponseState, + textEmbedded: boolean, +): ReactNodeList { + if (enableFloat && resourcesFromLink(props)) { + if (textEmbedded) { + // This link follows text but we aren't writing a tag. while not as efficient as possible we need + // to be safe and assume text will follow by inserting a textSeparator + target.push(textSeparator); + } + // We have converted this link exclusively to a resource and no longer + // need to emit it + return null; + } + + return pushLinkImpl(target, props, responseState); +} + +function pushLinkImpl( + target: Array, + props: Object, + responseState: ResponseState, ): ReactNodeList { const isStylesheet = props.rel === 'stylesheet'; target.push(startChunkForTag('link')); @@ -1106,15 +1164,9 @@ function pushLink( 'use `dangerouslySetInnerHTML`.', ); case 'precedence': { - if (isStylesheet) { - if (propValue === true || typeof propValue === 'string') { - pushAttribute(target, responseState, 'data-rprec', propValue); - } else if (__DEV__) { - throw new Error( - `the "precedence" prop for links to stylesheets expects to receive a string but received something of type "${typeof propValue}" instead.`, - ); - } - break; + if (enableFloat && isStylesheet) { + // precedence is a reversed property for stylesheets to opt-into resource semantcs + continue; } // intentionally fall through } @@ -1269,9 +1321,15 @@ function pushStartHead( props: Object, tag: string, responseState: ResponseState, + formatContext: FormatContext, ): ReactNodeList { // Preamble type is nullable for feature off cases but is guaranteed when feature is on - target = enableFloat ? preamble : target; + target = + enableFloat && + formatContext.insertionMode === ROOT_HTML_MODE && + formatContext.preambleOpen + ? preamble + : target; return pushStartGenericElement(target, props, tag, responseState); } @@ -1281,13 +1339,11 @@ function pushStartHtml( preamble: Array, props: Object, tag: string, - formatContext: FormatContext, responseState: ResponseState, + formatContext: FormatContext, ): ReactNodeList { - // Preamble type is nullable for feature off cases but is guaranteed when feature is on - target = enableFloat ? preamble : target; - if (formatContext.insertionMode === ROOT_HTML_MODE) { + target = enableFloat && formatContext.preambleOpen ? preamble : target; // If we're rendering the html tag and we're at the root (i.e. not in foreignObject) // then we also emit the DOCTYPE as part of the root content as a convenience for // rendering the whole document. @@ -1517,6 +1573,7 @@ export function pushStartInstance( props: Object, responseState: ResponseState, formatContext: FormatContext, + textEmbedded: boolean, ): ReactNodeList { if (__DEV__) { validateARIAProperties(type, props); @@ -1570,7 +1627,7 @@ export function pushStartInstance( case 'title': return pushStartTitle(target, props, responseState); case 'link': - return pushLink(target, props, responseState); + return pushLink(target, props, responseState, textEmbedded); // Newline eating tags case 'listing': case 'pre': { @@ -1606,15 +1663,22 @@ export function pushStartInstance( } // Preamble start tags case 'head': - return pushStartHead(target, preamble, props, type, responseState); + return pushStartHead( + target, + preamble, + props, + type, + responseState, + formatContext, + ); case 'html': { return pushStartHtml( target, preamble, props, type, - formatContext, responseState, + formatContext, ); } default: { @@ -2001,65 +2065,172 @@ export function writeEndSegment( // } // } // -// function completeBoundary(suspenseBoundaryID, contentID) { -// // Find the fallback's first element. -// const suspenseIdNode = document.getElementById(suspenseBoundaryID); -// const contentNode = document.getElementById(contentID); -// // We'll detach the content node so that regardless of what happens next we don't leave in the tree. -// // This might also help by not causing recalcing each time we move a child from here to the target. -// contentNode.parentNode.removeChild(contentNode); -// if (!suspenseIdNode) { -// // The user must have already navigated away from this tree. -// // E.g. because the parent was hydrated. That's fine there's nothing to do -// // but we have to make sure that we already deleted the container node. -// return; +// const insertStyles = (function() { +// const resourceMap = new Map(); +// const precedences = new Map(); +// // We omit rel stylesheet because only stylesheets should have data-prec attribute +// // and we can concievably use another kind of link to act as a placeholder for a +// // precedence that does not yet have any resources. +// let lastResource, node; +// let thisDocument = document; + +// // Seed the precedence list with existing resources +// let nodes = thisDocument.querySelectorAll('link[data-rprec]'); +// for (let i = 0;node = nodes[i++];) { +// precedences.set(node.dataset.rprec, lastResource = node); // } -// // Find the boundary around the fallback. This is always the previous node. -// const suspenseNode = suspenseIdNode.previousSibling; -// -// // Clear all the existing children. This is complicated because -// // there can be embedded Suspense boundaries in the fallback. -// // This is similar to clearSuspenseBoundary in ReactDOMHostConfig. -// // TODO: We could avoid this if we never emitted suspense boundaries in fallback trees. -// // They never hydrate anyway. However, currently we support incrementally loading the fallback. -// const parentInstance = suspenseNode.parentNode; -// let node = suspenseNode.nextSibling; -// let depth = 0; -// do { -// if (node && node.nodeType === COMMENT_NODE) { -// const data = node.data; -// if (data === SUSPENSE_END_DATA) { -// if (depth === 0) { -// break; -// } else { -// depth--; + +// function getPromise(resourceEl) { +// let p = resourceEl._p = new Promise((re, rj) => { +// resourceEl.onload = re; +// resourceEl.onerror = rj; +// }).then(() => { +// p.s = 'l'; +// }, (e) => { +// p.s = 'e'; +// throw e; +// }); +// return p; +// } + +// return function insertStyle(styles) { +// let i = 0; +// let dependencies, style, href, precedence, attr, loadingState, resourceEl; + +// while (style = styles[i++]) { +// let j = 0; +// href = style[j++]; +// // We check if this resource is already in our resourceMap and reuse it if so. +// // If it is already loaded we don't return it as a depenendency since there is nothing +// // to wait for +// loadingState = resourceMap.get(href); +// if (loadingState) { +// if (loadingState.s !== 'l') { +// dependencies ? dependencies.push(loadingState) : dependencies = [loadingState]; // } -// } else if ( -// data === SUSPENSE_START_DATA || -// data === SUSPENSE_PENDING_START_DATA || -// data === SUSPENSE_FALLBACK_START_DATA -// ) { -// depth++; +// continue; +// } + +// // We construct our new resource element, looping over remaining attributes if any +// // setting them to the Element. +// resourceEl = thisDocument.createElement("link"); +// resourceEl.href = href; +// resourceEl.rel = 'stylesheet'; +// resourceEl.dataset.rprec = precedence = style[j++]; +// while(attr = style[j++]) { +// resourceEl.setAttribute(attr, style[j++]); +// } + +// // We stash a pending promise in our map by href which will resolve or reject +// // when the underlying resource loads or errors. We add it to the dependencies +// // array to be returned. +// loadingState = getPromise(resourceEl); +// resourceMap.set(href, loadingState); +// dependencies ? dependencies.push(loadingState) : dependencies = [loadingState]; + +// // The prior style resource is the last one placed at a given +// // precedence or the last resource itself which may be null. +// // We grab this value and then update the last resource for this +// // precedence to be the inserted element, updating the lastResource +// // pointer if needed. +// let prior = precedences.get(precedence) || lastResource; +// if (prior === lastResource) { +// lastResource = resourceEl +// } +// precedences.set(precedence, resourceEl) + +// // Finally, we insert the newly constructed instance at an appropriate location +// // in the Document. +// if (prior) { +// prior.parentNode.insertBefore(resourceEl, prior.nextSibling); +// } else { +// let head = thisDocument.head; +// head.insertBefore(resourceEl, head.firstChild); // } // } +// return dependencies; +// } +// })() // -// const nextNode = node.nextSibling; -// parentInstance.removeChild(node); -// node = nextNode; -// } while (node); +// const completeBoundary = (function() { +// function flipBoundary(suspenseBoundaryID, contentNode, errorDigest) { +// // Find the fallback's first element. +// const suspenseIdNode = document.getElementById(suspenseBoundaryID); +// if (!suspenseIdNode) { +// // The user must have already navigated away from this tree. +// // E.g. because the parent was hydrated. That's fine there's nothing to do +// // but we have to make sure that we already deleted the container node. +// return; +// } +// // Find the boundary around the fallback. This is always the previous node. +// const suspenseNode = suspenseIdNode.previousSibling; + +// if (contentNode) { +// // Clear all the existing children. This is complicated because +// // there can be embedded Suspense boundaries in the fallback. +// // This is similar to clearSuspenseBoundary in ReactDOMHostConfig. +// // TODO: We could avoid this if we never emitted suspense boundaries in fallback trees. +// // They never hydrate anyway. However, currently we support incrementally loading the fallback. +// const parentInstance = suspenseNode.parentNode; +// let node = suspenseNode.nextSibling; +// let depth = 0; +// do { +// if (node && node.nodeType === COMMENT_NODE) { +// const data = node.data; +// if (data === SUSPENSE_END_DATA) { +// if (depth === 0) { +// break; +// } else { +// depth--; +// } +// } else if ( +// data === SUSPENSE_START_DATA || +// data === SUSPENSE_PENDING_START_DATA || +// data === SUSPENSE_FALLBACK_START_DATA +// ) { +// depth++; +// } +// } // -// const endOfBoundary = node; +// const nextNode = node.nextSibling; +// parentInstance.removeChild(node); +// node = nextNode; +// } while (node); // -// // Insert all the children from the contentNode between the start and end of suspense boundary. -// while (contentNode.firstChild) { -// parentInstance.insertBefore(contentNode.firstChild, endOfBoundary); +// const endOfBoundary = node; +// +// // Insert all the children from the contentNode between the start and end of suspense boundary. +// while (contentNode.firstChild) { +// parentInstance.insertBefore(contentNode.firstChild, endOfBoundary); +// } +// +// suspenseNode.data = '$'; +// } else { +// suspenseNode.data = '$!'; +// suspenseIdNode.setAttribute('data-dgst', errorDigest) +// } +// +// if (suspenseNode._reactRetry) { +// suspenseNode._reactRetry(); +// } // } - -// suspenseNode.data = SUSPENSE_START_DATA; -// if (suspenseNode._reactRetry) { -// suspenseNode._reactRetry(); +// return function completeBoundary(suspenseBoundaryID, contentID, styleResources){ +// const contentNode = document.getElementById(contentID); +// // We'll detach the content node so that regardless of what happens next we don't leave in the tree. +// // This might also help by not causing recalcing each time we move a child from here to the target. +// contentNode.parentNode.removeChild(contentNode); +// if (styleResources) { +// const p = $RR(styleResources); +// if (p) { +// return Promise.all(p).then( +// flipBoundary.bind(null, suspenseBoundaryID, contentNode, null), +// flipBoundary.bind(null, suspenseBoundaryID, null, "Resource failed to load"), +// ); +// } +// } +// flipBoundary(suspenseBoundaryID, contentNode); // } -// } +// })() // // function completeSegment(containerID, placeholderID) { // const segmentContainer = document.getElementById(containerID); @@ -2079,8 +2250,179 @@ export function writeEndSegment( const completeSegmentFunction = 'function $RS(a,b){a=document.getElementById(a);b=document.getElementById(b);for(a.parentNode.removeChild(a);a.firstChild;)b.parentNode.insertBefore(a.firstChild,b);b.parentNode.removeChild(b)}'; +// const completeBoundaryFunction = +// 'function $RC(a,b){a=document.getElementById(a);b=document.getElementById(b);b.parentNode.removeChild(b);if(a){a=a.previousSibling;var f=a.parentNode,c=a.nextSibling,e=0;do{if(c&&8===c.nodeType){var d=c.data;if("/$"===d)if(0===e)break;else e--;else"$"!==d&&"$?"!==d&&"$!"!==d||e++}d=c.nextSibling;f.removeChild(c);c=d}while(c);for(;b.firstChild;)f.insertBefore(b.firstChild,c);a.data="$";a._reactRetry&&a._reactRetry()}};'; +// const completeBoundaryFunction = +// '$RC=function(){function h(e,f){var a=document.getElementById(e),c=document.getElementById(f);c.parentNode.removeChild(c);if(a){a=a.previousSibling;var k=a.parentNode,b=a.nextSibling,g=0;do{if(b&&8===b.nodeType){var d=b.data;if("/$"===d)if(0===g)break;else g--;else"$"!==d&&"$?"!==d&&"$!"!==d||g++}d=b.nextSibling;k.removeChild(b);b=d}while(b);for(;c.firstChild;)k.insertBefore(c.firstChild,b);a.data="$";a._reactRetry&&a._reactRetry()}}return function(e,f,a){if(a&&(a=$RR(a)))return Promise.all(a).then(h.bind(null,e,f))["catch"](function(c){return console.log("caught",c)});h(e,f)}}()' +const styleInsertionFunction = + '$RR=function(){function r(f){var g=f._p=(new Promise(function(b,a){f.onload=b;f.onerror=a})).then(function(){g.s="l"},function(b){g.s="e";throw b;});return g}for(var q=new Map,m=new Map,h,n,p=document,t=p.querySelectorAll("link[data-rprec]"),u=0;n=t[u++];)m.set(n.dataset.rprec,h=n);return function(f){for(var g=0,b,a,k,e,c,d;a=f[g++];){var l=0;k=a[l++];if(c=q.get(k))"l"!==c.s&&(b?b.push(c):b=[c]);else{d=p.createElement("link");d.href=k;d.rel="stylesheet";for(d.dataset.rprec=e=a[l++];c=a[l++];)d.setAttribute(c,a[l++]);c=r(d);q.set(k,c);b?b.push(c):b=[c];a=m.get(e)||h;a===h&&(h=d);m.set(e,d);a?a.parentNode.insertBefore(d,a.nextSibling):(e=p.head,e.insertBefore(d,e.firstChild))}}return b}}();'; +// const styleInsertionFunction = `$RR = (function() { +// const resourceMap = new Map(); +// const precedences = new Map(); +// // We omit rel stylesheet because only stylesheets should have data-prec attribute +// // and we can concievably use another kind of link to act as a placeholder for a +// // precedence that does not yet have any resources. +// let lastResource, node; +// let thisDocument = document; + +// // Seed the precedence list with existing resources +// let nodes = thisDocument.querySelectorAll('link[data-rprec]'); +// for (let i = 0;node = nodes[i++];) { +// precedences.set(node.dataset.rprec, lastResource = node); +// } + +// function getPromise(resourceEl) { +// let p = resourceEl._p = new Promise((re, rj) => { +// resourceEl.onload = re; +// resourceEl.onerror = rj; +// }).then(() => { +// p.s = 'l'; +// }, (e) => { +// p.s = 'e'; +// throw e; +// }); +// return p; +// } + +// return function insertStyle(styles) { +// let i = 0; +// let dependencies, style, href, precedence, attr, loadingState, resourceEl; + +// while (style = styles[i++]) { +// let j = 0; +// href = style[j++]; +// // We check if this resource is already in our resourceMap and reuse it if so. +// // If it is already loaded we don't return it as a depenendency since there is nothing +// // to wait for +// loadingState = resourceMap.get(href); +// if (loadingState) { +// if (loadingState.s !== 'l') { +// dependencies ? dependencies.push(loadingState) : dependencies = [loadingState]; +// } +// continue; +// } + +// // We construct our new resource element, looping over remaining attributes if any +// // setting them to the Element. +// resourceEl = thisDocument.createElement("link"); +// resourceEl.href = href; +// resourceEl.rel = 'stylesheet'; +// resourceEl.dataset.rprec = precedence = style[j++]; +// while(attr = style[j++]) { +// resourceEl.setAttribute(attr, style[j++]); +// } + +// // We stash a pending promise in our map by href which will resolve or reject +// // when the underlying resource loads or errors. We add it to the dependencies +// // array to be returned. +// loadingState = getPromise(resourceEl); +// resourceMap.set(href, loadingState); +// dependencies ? dependencies.push(loadingState) : dependencies = [loadingState]; + +// // The prior style resource is the last one placed at a given +// // precedence or the last resource itself which may be null. +// // We grab this value and then update the last resource for this +// // precedence to be the inserted element, updating the lastResource +// // pointer if needed. +// let prior = precedences.get(precedence) || lastResource; +// if (prior === lastResource) { +// lastResource = resourceEl +// } +// precedences.set(precedence, resourceEl) + +// // Finally, we insert the newly constructed instance at an appropriate location +// // in the Document. +// if (prior) { +// prior.parentNode.insertBefore(resourceEl, prior.nextSibling); +// } else { +// let head = thisDocument.head; +// head.insertBefore(resourceEl, head.firstChild); +// } +// } +// return dependencies; +// } +// })()`; const completeBoundaryFunction = - 'function $RC(a,b){a=document.getElementById(a);b=document.getElementById(b);b.parentNode.removeChild(b);if(a){a=a.previousSibling;var f=a.parentNode,c=a.nextSibling,e=0;do{if(c&&8===c.nodeType){var d=c.data;if("/$"===d)if(0===e)break;else e--;else"$"!==d&&"$?"!==d&&"$!"!==d||e++}d=c.nextSibling;f.removeChild(c);c=d}while(c);for(;b.firstChild;)f.insertBefore(b.firstChild,c);a.data="$";a._reactRetry&&a._reactRetry()}}'; + '$RC=function(){function f(a,c,d){var b=document.getElementById(a);if(b){a=b.previousSibling;if(c){d=a.parentNode;b=a.nextSibling;var g=0;do{if(b&&8===b.nodeType){var e=b.data;if("/$"===e)if(0===g)break;else g--;else"$"!==e&&"$?"!==e&&"$!"!==e||g++}e=b.nextSibling;d.removeChild(b);b=e}while(b);for(;c.firstChild;)d.insertBefore(c.firstChild,b);a.data="$"}else a.data="$!",b.setAttribute("data-dgst",d);a._reactRetry&&a._reactRetry()}}return function(a,c,d){c=document.getElementById(c);c.parentNode.removeChild(c);if(d&&(d=$RR(d)))return Promise.all(d).then(f.bind(null,a,c,null),f.bind(null,a,null,"Resource failed to load"));f(a,c)}}();'; +// const completeBoundaryFunction = `$RC = (function() { +// function flipBoundary(suspenseBoundaryID, contentNode, errorDigest) { +// // Find the fallback's first element. +// const suspenseIdNode = document.getElementById(suspenseBoundaryID); +// if (!suspenseIdNode) { +// // The user must have already navigated away from this tree. +// // E.g. because the parent was hydrated. That's fine there's nothing to do +// // but we have to make sure that we already deleted the container node. +// return; +// } +// // Find the boundary around the fallback. This is always the previous node. +// const suspenseNode = suspenseIdNode.previousSibling; + +// if (contentNode) { +// // Clear all the existing children. This is complicated because +// // there can be embedded Suspense boundaries in the fallback. +// // This is similar to clearSuspenseBoundary in ReactDOMHostConfig. +// // TODO: We could avoid this if we never emitted suspense boundaries in fallback trees. +// // They never hydrate anyway. However, currently we support incrementally loading the fallback. +// const parentInstance = suspenseNode.parentNode; +// let node = suspenseNode.nextSibling; +// let depth = 0; +// do { +// if (node && node.nodeType === 8) { +// const data = node.data; +// if (data === '/$') { +// if (depth === 0) { +// break; +// } else { +// depth--; +// } +// } else if ( +// data === '$' || +// data === '$?' || +// data === '$!' +// ) { +// depth++; +// } +// } + +// const nextNode = node.nextSibling; +// parentInstance.removeChild(node); +// node = nextNode; +// } while (node); + +// const endOfBoundary = node; + +// // Insert all the children from the contentNode between the start and end of suspense boundary. +// while (contentNode.firstChild) { +// parentInstance.insertBefore(contentNode.firstChild, endOfBoundary); +// } + +// suspenseNode.data = '$'; +// } else { +// suspenseNode.data = '$!'; +// suspenseIdNode.setAttribute('data-dgst', errorDigest) +// } + +// if (suspenseNode._reactRetry) { +// suspenseNode._reactRetry(); +// } +// } +// return function completeBoundary(suspenseBoundaryID, contentID, styleResources){ +// const contentNode = document.getElementById(contentID); +// // We'll detach the content node so that regardless of what happens next we don't leave in the tree. +// // This might also help by not causing recalcing each time we move a child from here to the target. +// contentNode.parentNode.removeChild(contentNode); +// if (styleResources) { +// const p = $RR(styleResources); +// if (p) { +// return Promise.all(p).then( +// flipBoundary.bind(null, suspenseBoundaryID, contentNode, null), +// flipBoundary.bind(null, suspenseBoundaryID, null, "Resource failed to load"), +// ); +// } +// } +// flipBoundary(suspenseBoundaryID, contentNode); +// } +// })()`; const clientRenderFunction = 'function $RX(b,c,d,e){var a=document.getElementById(b);a&&(b=a.previousSibling,b.data="$!",a=a.dataset,c&&(a.dgst=c),d&&(a.msg=d),e&&(a.stck=e),b._reactRetry&&b._reactRetry())}'; @@ -2114,25 +2456,55 @@ export function writeCompletedSegmentInstruction( return writeChunkAndReturn(destination, completeSegmentScript3); } +const completeBoundaryScript1FullWithStyleInsertion = stringToPrecomputedChunk( + styleInsertionFunction + ';' + completeBoundaryFunction + ';$RC("', +); const completeBoundaryScript1Full = stringToPrecomputedChunk( completeBoundaryFunction + ';$RC("', ); +const completeBoundaryScript1PartialWithStyleInsertion = stringToPrecomputedChunk( + styleInsertionFunction + ';$RC("', +); const completeBoundaryScript1Partial = stringToPrecomputedChunk('$RC("'); const completeBoundaryScript2 = stringToPrecomputedChunk('","'); -const completeBoundaryScript3 = stringToPrecomputedChunk('")'); +const completeBoundaryScript3 = stringToPrecomputedChunk('"'); +const completeBoundaryScript4 = stringToPrecomputedChunk(')'); export function writeCompletedBoundaryInstruction( destination: Destination, responseState: ResponseState, boundaryID: SuspenseBoundaryID, contentSegmentID: number, + boundaryResources: BoundaryResources, ): boolean { + let hasStyleDependencies; + if (enableFloat) { + hasStyleDependencies = hasStyleResourceDependencies(boundaryResources); + } writeChunk(destination, responseState.startInlineScript); if (!responseState.sentCompleteBoundaryFunction) { - // The first time we write this, we'll need to include the full implementation. - responseState.sentCompleteBoundaryFunction = true; - writeChunk(destination, completeBoundaryScript1Full); + if (enableFloat) { + responseState.sentCompleteBoundaryFunction = true; + if (hasStyleDependencies && !responseState.sentStyleInsertionFunction) { + responseState.sentStyleInsertionFunction = true; + writeChunk(destination, completeBoundaryScript1FullWithStyleInsertion); + } else { + writeChunk(destination, completeBoundaryScript1Full); + } + } else { + // The first time we write this, we'll need to include the full implementation. + responseState.sentCompleteBoundaryFunction = true; + writeChunk(destination, completeBoundaryScript1Full); + } } else { + if ( + enableFloat && + hasStyleDependencies && + !responseState.sentStyleInsertionFunction + ) { + responseState.sentStyleInsertionFunction = true; + writeChunk(destination, completeBoundaryScript1PartialWithStyleInsertion); + } // Future calls can just reuse the same function. writeChunk(destination, completeBoundaryScript1Partial); } @@ -2148,7 +2520,13 @@ export function writeCompletedBoundaryInstruction( writeChunk(destination, completeBoundaryScript2); writeChunk(destination, responseState.segmentPrefix); writeChunk(destination, formattedContentID); - return writeChunkAndReturn(destination, completeBoundaryScript3); + if (enableFloat && hasStyleDependencies) { + writeChunk(destination, stringToChunk('",')); + writeStyleResourceDependencies(destination, boundaryResources); + } else { + writeChunk(destination, completeBoundaryScript3); + } + return writeChunkAndReturn(destination, completeBoundaryScript4); } const clientRenderScript1Full = stringToPrecomputedChunk( @@ -2209,10 +2587,10 @@ export function writeClientRenderBoundaryInstruction( return writeChunkAndReturn(destination, clientRenderScript2); } -const regexForJSStringsInScripts = /[<\u2028\u2029]/g; +const regexForJSStringsInInstructionScripts = /[<\u2028\u2029]/g; function escapeJSStringsForInstructionScripts(input: string): string { const escaped = JSON.stringify(input); - return escaped.replace(regexForJSStringsInScripts, match => { + return escaped.replace(regexForJSStringsInInstructionScripts, match => { switch (match) { // santizing breaking out of strings and script tags case '<': @@ -2230,3 +2608,334 @@ function escapeJSStringsForInstructionScripts(input: string): string { } }); } + +const regexForJSStringsInScripts = /[&><\u2028\u2029]/g; +function escapeJSObjectForInstructionScripts(input: Object): string { + const escaped = JSON.stringify(input); + return escaped.replace(regexForJSStringsInScripts, match => { + switch (match) { + // santizing breaking out of strings and script tags + case '&': + return '\\u0026'; + case '>': + return '\\u003e'; + case '<': + return '\\u003c'; + case '\u2028': + return '\\u2028'; + case '\u2029': + return '\\u2029'; + default: { + // eslint-disable-next-line react-internal/prod-error-codes + throw new Error( + 'escapeJSObjectForInstructionScripts encountered a match it does not know how to replace. this means the match regex and the replacement characters are no longer in sync. This is a bug in React', + ); + } + } + }); +} + +export function writeInitialResources( + destination: Destination, + resources: Resources, + responseState: ResponseState, +): boolean { + const explicitPreloadsTarget = []; + const remainingTarget = []; + + const {precedences, explicitPreloads, implicitPreloads} = resources; + + // Flush stylesheets first by earliest precedence + precedences.forEach(precedenceResources => { + precedenceResources.forEach(resource => { + // resources should not already be flushed so we elide this check + pushLinkImpl(remainingTarget, resource.props, responseState); + resource.flushed = true; + resource.inShell = true; + resource.hint.flushed = true; + }); + }); + + explicitPreloads.forEach(resource => { + if (!resource.flushed) { + pushLinkImpl(explicitPreloadsTarget, resource.props, responseState); + resource.flushed = true; + } + }); + explicitPreloads.clear(); + + implicitPreloads.forEach(resource => { + if (!resource.flushed) { + pushLinkImpl(remainingTarget, resource.props, responseState); + resource.flushed = true; + } + }); + implicitPreloads.clear(); + + let i; + let r = true; + for (i = 0; i < explicitPreloadsTarget.length - 1; i++) { + writeChunk(destination, explicitPreloadsTarget[i]); + } + if (i < explicitPreloadsTarget.length) { + r = writeChunkAndReturn(destination, explicitPreloadsTarget[i]); + } + + for (i = 0; i < remainingTarget.length - 1; i++) { + writeChunk(destination, remainingTarget[i]); + } + if (i < remainingTarget.length) { + r = writeChunkAndReturn(destination, remainingTarget[i]); + } + return r; +} + +export function writeImmediateResources( + destination: Destination, + resources: Resources, + responseState: ResponseState, +): boolean { + const {explicitPreloads, implicitPreloads} = resources; + const target = []; + + explicitPreloads.forEach(resource => { + if (!resource.flushed) { + pushLinkImpl(target, resource.props, responseState); + resource.flushed = true; + } + }); + explicitPreloads.clear(); + + implicitPreloads.forEach(resource => { + if (!resource.flushed) { + pushLinkImpl(target, resource.props, responseState); + resource.flushed = true; + } + }); + implicitPreloads.clear(); + + let i = 0; + for (; i < target.length - 1; i++) { + writeChunk(destination, target[i]); + } + if (i < target.length) { + return writeChunkAndReturn(destination, target[i]); + } + return false; +} + +function hasStyleResourceDependencies( + boundaryResources: BoundaryResources, +): boolean { + const iter = boundaryResources.values(); + // At the moment boundaries only accumulate style resources + // so we assume the type is correct and don't check it + while (true) { + const {value: resource} = iter.next(); + if (!resource) break; + + // If every style Resource flushed in the shell we do not need to send + // any dependencies + if (!resource.inShell) { + return true; + } + } + return false; +} + +const arrayFirstOpenBracket = stringToPrecomputedChunk('['); +const arraySubsequentOpenBracket = stringToPrecomputedChunk(',['); +const arrayInterstitial = stringToPrecomputedChunk(','); +const arrayCloseBracket = stringToPrecomputedChunk(']'); + +function writeStyleResourceDependencies( + destination: Destination, + boundaryResources: BoundaryResources, +): void { + writeChunk(destination, arrayFirstOpenBracket); + + let nextArrayOpenBrackChunk = arrayFirstOpenBracket; + boundaryResources.forEach(resource => { + if (resource.inShell) { + // We can elide this dependency because it was flushed in the shell and + // should be ready before content is shown on the client + } else if (resource.flushed) { + writeChunk(destination, nextArrayOpenBrackChunk); + writeStyleResourceDependencyHrefOnly(destination, resource.href); + writeChunk(destination, arrayCloseBracket); + nextArrayOpenBrackChunk = arraySubsequentOpenBracket; + } else { + writeChunk(destination, nextArrayOpenBrackChunk); + writeStyleResourceDependency( + destination, + resource.href, + resource.precedence, + resource.props, + ); + writeChunk(destination, arrayCloseBracket); + nextArrayOpenBrackChunk = arraySubsequentOpenBracket; + + resource.flushed = true; + resource.hint.flushed = true; + } + }); + writeChunk(destination, arrayCloseBracket); +} + +function writeStyleResourceDependencyHrefOnly( + destination: Destination, + href: string, +) { + // We should actually enforce this earlier when the resource is created but for + // now we make sure we are actually dealing with a string here. + if (__DEV__) { + checkAttributeStringCoercion(href, 'href'); + } + const coercedHref = '' + (href: any); + writeChunk( + destination, + stringToChunk(escapeJSObjectForInstructionScripts(coercedHref)), + ); +} + +function writeStyleResourceDependency( + destination: Destination, + href: string, + precedence: string, + props: Object, +) { + if (__DEV__) { + checkAttributeStringCoercion(href, 'href'); + } + const coercedHref = '' + (href: any); + sanitizeURL(coercedHref); + writeChunk( + destination, + stringToChunk(escapeJSObjectForInstructionScripts(coercedHref)), + ); + + if (__DEV__) { + checkAttributeStringCoercion(precedence, 'precedence'); + } + const coercedPrecedence = '' + (precedence: any); + writeChunk(destination, arrayInterstitial); + writeChunk( + destination, + stringToChunk(escapeJSObjectForInstructionScripts(coercedPrecedence)), + ); + + for (const propKey in props) { + if (hasOwnProperty.call(props, propKey)) { + const propValue = props[propKey]; + if (propValue == null) { + continue; + } + switch (propKey) { + case 'href': + case 'rel': + case 'precedence': + case 'data-rprec': { + break; + } + case 'children': + case 'dangerouslySetInnerHTML': + throw new Error( + `${'link'} is a self-closing tag and must neither have \`children\` nor ` + + 'use `dangerouslySetInnerHTML`.', + ); + // eslint-disable-next-line-no-fallthrough + default: + writeStyleResourceAttribute(destination, propKey, propValue); + break; + } + } + } + return null; +} + +function writeStyleResourceAttribute( + destination: Destination, + name: string, + value: string | boolean | number | Function | Object, // not null or undefined +): void { + let attributeName = name.toLowerCase(); + let attributeValue; + switch (typeof value) { + case 'function': + case 'symbol': + return; + } + + switch (name) { + // Reserved names + case 'innerHTML': + case 'dangerouslySetInnerHTML': + case 'suppressContentEditableWarning': + case 'suppressHydrationWarning': + case 'style': + // Ignored + return; + + // Attribute renames + case 'className': + attributeName = 'class'; + break; + + // Booleans + case 'hidden': + if (value === false) { + return; + } + attributeValue = ''; + break; + + // Santized URLs + case 'src': + case 'href': { + if (__DEV__) { + checkAttributeStringCoercion(value, attributeName); + } + attributeValue = '' + (value: any); + sanitizeURL(attributeValue); + break; + } + default: { + if (!isAttributeNameSafe(name)) { + return; + } + } + } + + if ( + // shouldIgnoreAttribute + // We have already filtered out null/undefined and reserved words. + name.length > 2 && + (name[0] === 'o' || name[0] === 'O') && + (name[1] === 'n' || name[1] === 'N') + ) { + return; + } + + if (__DEV__) { + checkAttributeStringCoercion(value, attributeName); + } + attributeValue = '' + (value: any); + writeChunk(destination, arrayInterstitial); + writeChunk( + destination, + stringToChunk(escapeJSObjectForInstructionScripts(attributeName)), + ); + writeChunk(destination, arrayInterstitial); + writeChunk( + destination, + stringToChunk(escapeJSObjectForInstructionScripts(attributeValue)), + ); +} + +export function prepareToRender(resources: Resources) { + prepareToRenderImpl(resources); +} + +export function cleanupAfterRender() { + cleanupAfterRenderImpl(); +} diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js b/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js index 375562e80b56d..dcd1178b5e1a7 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js @@ -40,6 +40,7 @@ export type ResponseState = { sentCompleteSegmentFunction: boolean, sentCompleteBoundaryFunction: boolean, sentClientRenderFunction: boolean, + sentStyleInsertionFunction: boolean, // This is an extra field for the legacy renderer generateStaticMarkup: boolean, }; @@ -61,6 +62,7 @@ export function createResponseState( sentCompleteSegmentFunction: responseState.sentCompleteSegmentFunction, sentCompleteBoundaryFunction: responseState.sentCompleteBoundaryFunction, sentClientRenderFunction: responseState.sentClientRenderFunction, + sentStyleInsertionFunction: responseState.sentStyleInsertionFunction, // This is an extra field for the legacy renderer generateStaticMarkup, }; @@ -70,10 +72,13 @@ export function createRootFormatContext(): FormatContext { return { insertionMode: HTML_MODE, // We skip the root mode because we don't want to emit the DOCTYPE in legacy mode. selectedValue: null, + preambleOpen: true, }; } export type { + Resources, + BoundaryResources, FormatContext, SuspenseBoundaryID, } from './ReactDOMServerFormatConfig'; @@ -96,6 +101,15 @@ export { writeEndPendingSuspenseBoundary, writePlaceholder, writeCompletedRoot, + createResources, + createBoundaryResources, + writeInitialResources, + writeImmediateResources, + hoistResources, + hoistResourcesToRoot, + setCurrentlyRenderingBoundaryResources, + prepareToRender, + cleanupAfterRender, } from './ReactDOMServerFormatConfig'; import {stringToChunk} from 'react-server/src/ReactServerStreamConfig'; 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..d8729ec7fe5c0 --- /dev/null +++ b/packages/react-dom-bindings/src/shared/ReactDOMDispatcher.js @@ -0,0 +1,18 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +type DispatcherType = { + [string]: mixed, +}; + +const Dispatcher: {current: null | DispatcherType} = { + current: null, +}; + +export default Dispatcher; diff --git a/packages/react-dom-bindings/src/shared/ReactDOMFloat.js b/packages/react-dom-bindings/src/shared/ReactDOMFloat.js new file mode 100644 index 0000000000000..998d4c2a4f0f0 --- /dev/null +++ b/packages/react-dom-bindings/src/shared/ReactDOMFloat.js @@ -0,0 +1,21 @@ +import ReactDOMSharedInternals from '../ReactDOMSharedInternals'; + +export function preinit() { + const dispatcher = ReactDOMSharedInternals.Dispatcher.current; + if (dispatcher) { + dispatcher.preinit.apply(this, arguments); + } + // 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 + // so we favor silent bailout over warning or erroring. +} + +export function preload() { + const dispatcher = ReactDOMSharedInternals.Dispatcher.current; + if (dispatcher) { + dispatcher.preload.apply(this, arguments); + } + // 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. +} diff --git a/packages/react-dom-bindings/src/shared/ReactDOMResourceValidation.js b/packages/react-dom-bindings/src/shared/ReactDOMResourceValidation.js new file mode 100644 index 0000000000000..111803bde4d01 --- /dev/null +++ b/packages/react-dom-bindings/src/shared/ReactDOMResourceValidation.js @@ -0,0 +1,601 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 hasOwnProperty from 'shared/hasOwnProperty'; + +type Props = {[string]: mixed}; + +export function validateUnmatchedLinkResourceProps( + pendingProps: Props, + currentProps: ?Props, +) { + if (__DEV__) { + if (pendingProps.rel !== 'font' && pendingProps.rel !== 'style') { + if (currentProps != null) { + const originalResourceName = + typeof currentProps.href === 'string' + ? `Resource with href "${currentProps.href}"` + : 'Resource'; + const originalRelStatement = getValueDescriptorExpectingEnumForWarning( + currentProps.rel, + ); + const pendingRelStatement = getValueDescriptorExpectingEnumForWarning( + pendingProps.rel, + ); + const pendingHrefStatement = + typeof pendingProps.href === 'string' + ? ` and the updated href is "${pendingProps.href}"` + : ''; + console.error( + 'A previously rendered as a %s but was updated with a rel type that is not' + + ' valid for a Resource type. Generally Resources are not expected to ever have updated' + + ' props however in some limited circumstances it can be valid when changing the href.' + + ' When React encounters props that invalidate the Resource it is the same as not rendering' + + ' a Resource at all. valid rel types for Resources are "font" and "style". The previous' + + ' rel for this instance was %s. The updated rel is %s%s.', + originalResourceName, + originalRelStatement, + pendingRelStatement, + pendingHrefStatement, + ); + } else { + const pendingRelStatement = getValueDescriptorExpectingEnumForWarning( + pendingProps.rel, + ); + console.error( + 'A is rendering as a Resource but has an invalid rel property. The rel encountered is %s.' + + ' This is a bug in React.', + pendingRelStatement, + ); + } + } + } +} + +export function validatePreloadResourceDifference( + originalProps: any, + originalImplicit: boolean, + latestProps: any, + latestImplicit: boolean, +) { + if (__DEV__) { + const {href} = originalProps; + const originalWarningName = getResourceNameForWarning( + 'preload', + originalProps, + originalImplicit, + ); + const latestWarningName = getResourceNameForWarning( + 'preload', + latestProps, + latestImplicit, + ); + + if (latestProps.as !== originalProps.as) { + console.error( + 'A %s is using the same href "%s" as a %s. This is always an error and React will only keep the first preload' + + ' for any given href, discarding subsequent instances. To fix, find where you are using this href in link' + + ' tags or in calls to ReactDOM.preload() or ReactDOM.preinit() and either make the Resource types agree or' + + ' update the hrefs to be distinct for different Resource types.', + latestWarningName, + href, + originalWarningName, + ); + } else { + let missingProps = null; + let extraProps = null; + let differentProps = null; + if (originalProps.media != null && latestProps.media == null) { + missingProps = missingProps || {}; + missingProps.media = originalProps.media; + } + + for (const propName in latestProps) { + const propValue = latestProps[propName]; + const originalValue = originalProps[propName]; + + if (propValue != null && propValue !== originalValue) { + if (originalValue == null) { + extraProps = extraProps || {}; + extraProps[propName] = propValue; + } else { + differentProps = differentProps || {}; + differentProps[propName] = { + original: originalValue, + latest: propValue, + }; + } + } + } + + if (missingProps || extraProps || differentProps) { + warnDifferentProps( + href, + originalWarningName, + latestWarningName, + extraProps, + missingProps, + differentProps, + ); + } + } + } +} + +export function validateStyleResourceDifference( + originalProps: any, + latestProps: any, +) { + if (__DEV__) { + const {href} = originalProps; + // eslint-disable-next-line no-labels + const originalWarningName = getResourceNameForWarning( + 'style', + originalProps, + false, + ); + const latestWarningName = getResourceNameForWarning( + 'style', + latestProps, + false, + ); + let missingProps = null; + let extraProps = null; + let differentProps = null; + if (originalProps.media != null && latestProps.media == null) { + missingProps = missingProps || {}; + missingProps.media = originalProps.media; + } + + for (let propName in latestProps) { + const propValue = latestProps[propName]; + const originalValue = originalProps[propName]; + + if (propValue != null && propValue !== originalValue) { + propName = propName === 'data-rprec' ? 'precedence' : propName; + if (originalValue == null) { + extraProps = extraProps || {}; + extraProps[propName] = propValue; + } else { + differentProps = differentProps || {}; + differentProps[propName] = { + original: originalValue, + latest: propValue, + }; + } + } + } + + if (missingProps || extraProps || differentProps) { + warnDifferentProps( + href, + originalWarningName, + latestWarningName, + extraProps, + missingProps, + differentProps, + ); + } + } +} + +export function validateStyleAndHintProps( + preloadProps: any, + styleProps: any, + implicitPreload: boolean, +) { + if (__DEV__) { + const {href} = preloadProps; + + const originalWarningName = getResourceNameForWarning( + 'preload', + preloadProps, + implicitPreload, + ); + const latestWarningName = getResourceNameForWarning( + 'style', + styleProps, + false, + ); + + if (preloadProps.as !== 'style') { + console.error( + 'While creating a %s for href "%s" a %s for this same href was found. When preloading a stylesheet the' + + ' "as" prop must be of type "style". This most likely ocurred by rending a preload link with an incorrect' + + ' "as" prop or by calling ReactDOM.preload with an incorrect "as" option.', + latestWarningName, + href, + originalWarningName, + ); + } + + let missingProps = null; + let extraProps = null; + let differentProps = null; + + for (const propName in styleProps) { + const styleValue = styleProps[propName]; + const preloadValue = preloadProps[propName]; + switch (propName) { + // Check for difference on specific props that cross over or influence + // the relationship between the preload and stylesheet + case 'crossOrigin': + case 'referrerPolicy': + case 'media': + case 'title': { + if ( + preloadValue !== styleValue && + !(preloadValue == null && styleValue == null) + ) { + if (styleValue == null) { + missingProps = missingProps || {}; + missingProps[propName] = preloadValue; + } else if (preloadValue == null) { + extraProps = extraProps || {}; + extraProps[propName] = styleValue; + } else { + differentProps = differentProps || {}; + differentProps[propName] = { + original: preloadValue, + latest: styleValue, + }; + } + } + } + } + } + + if (missingProps || extraProps || differentProps) { + warnDifferentProps( + href, + originalWarningName, + latestWarningName, + extraProps, + missingProps, + differentProps, + ); + } + } +} + +function warnDifferentProps( + href: string, + originalName: string, + latestName: string, + extraProps: ?{[string]: any}, + missingProps: ?{[string]: any}, + differentProps: ?{[string]: {original: any, latest: any}}, +): void { + if (__DEV__) { + const juxtaposedNameStatement = + latestName === originalName + ? 'an earlier instance of this Resource' + : `a ${originalName} with the same href`; + + let comparisonStatement = ''; + if (missingProps !== null && typeof missingProps === 'object') { + for (const propName in missingProps) { + comparisonStatement += `\n ${propName}: missing or null in latest props, "${missingProps[propName]}" in original props`; + } + } + if (extraProps !== null && typeof extraProps === 'object') { + for (const propName in extraProps) { + comparisonStatement += `\n ${propName}: "${extraProps[propName]}" in latest props, missing or null in original props`; + } + } + if (differentProps !== null && typeof differentProps === 'object') { + for (const propName in differentProps) { + comparisonStatement += `\n ${propName}: "${differentProps[propName].latest}" in latest props, "${differentProps[propName].original}" in original props`; + } + } + + console.error( + 'A %s with href "%s" has props that disagree with those found on %s. Resources always use the props' + + ' that were provided the first time they are encountered so any differences will be ignored. Please' + + ' update Resources that share an href to have props that agree. The differences are described below.%s', + latestName, + href, + juxtaposedNameStatement, + comparisonStatement, + ); + } +} + +function getResourceNameForWarning( + type: string, + props: Object, + implicit: boolean, +) { + if (__DEV__) { + switch (type) { + case 'style': { + return 'style Resource'; + } + case 'preload': { + if (implicit) { + return `preload for a ${props.as} Resource`; + } + return `preload Resource (as "${props.as}")`; + } + } + } + return 'Resource'; +} + +export function validateHrefKeyedUpdatedProps( + pendingProps: Props, + currentProps: Props, +): boolean { + if (__DEV__) { + // This function should never be called if we don't have hrefs so we don't bother considering + // Whether they are null or undefined + if (pendingProps.href === currentProps.href) { + // If we have the same href we need all other props to be the same + let missingProps; + let extraProps; + let differentProps; + const allProps = Array.from( + new Set(Object.keys(currentProps).concat(Object.keys(pendingProps))), + ); + for (let i = 0; i < allProps.length; i++) { + const propName = allProps[i]; + const pendingValue = pendingProps[propName]; + const currentValue = currentProps[propName]; + if ( + pendingValue !== currentValue && + !(pendingValue == null && currentValue == null) + ) { + if (pendingValue == null) { + missingProps = missingProps || {}; + missingProps[propName] = currentValue; + } else if (currentValue == null) { + extraProps = extraProps || {}; + extraProps[propName] = pendingValue; + } else { + differentProps = differentProps || {}; + differentProps[propName] = { + original: currentValue, + latest: pendingValue, + }; + } + } + } + if (missingProps || extraProps || differentProps) { + const latestWarningName = getResourceNameForWarning( + 'style', + currentProps, + false, + ); + + let comparisonStatement = ''; + if (missingProps !== null && typeof missingProps === 'object') { + for (const propName in missingProps) { + comparisonStatement += `\n ${propName}: missing or null in latest props, "${missingProps[propName]}" in original props`; + } + } + if (extraProps !== null && typeof extraProps === 'object') { + for (const propName in extraProps) { + comparisonStatement += `\n ${propName}: "${extraProps[propName]}" in latest props, missing or null in original props`; + } + } + if (differentProps !== null && typeof differentProps === 'object') { + for (const propName in differentProps) { + comparisonStatement += `\n ${propName}: "${differentProps[propName].latest}" in latest props, "${differentProps[propName].original}" in original props`; + } + } + console.error( + 'A %s with href "%s" recieved new props with different values from the props used' + + ' when this Resource was first rendered. React will only use the props provided when' + + ' this resource was first rendered until a new href is provided. Unlike conventional' + + ' DOM elements, Resources instances do not have a one to one correspondence with Elements' + + ' in the DOM and as such, every instance of a Resource for a single Resource identifier' + + ' (href) must have props that agree with each other. The differences are described below.%s', + latestWarningName, + currentProps.href, + comparisonStatement, + ); + return true; + } + } + } + return false; +} + +export function validateLinkPropsForStyleResource(props: Props): boolean { + if (__DEV__) { + // This should only be called when we know we are opting into Resource semantics (i.e. precedence is not null) + const {href, onLoad, onError, disabled} = props; + const allProps = ['onLoad', 'onError', 'disabled']; + const includedProps = []; + if (onLoad) includedProps.push('onLoad'); + if (onError) includedProps.push('onError'); + if (disabled != null) includedProps.push('disabled'); + + const allPropsUnionPhrase = propNamesListJoin(allProps, 'or'); + let includedPropsPhrase = propNamesListJoin(includedProps, 'and'); + includedPropsPhrase += includedProps.length === 1 ? ' prop' : ' props'; + + if (includedProps.length) { + console.error( + 'A link (rel="stylesheet") element with href "%s" has the precedence prop but also included the %s.' + + ' When using %s React will opt out of Resource behavior. If you meant for this' + + ' element to be treated as a Resource remove the %s. Otherwise remove the precedence prop.', + href, + includedPropsPhrase, + allPropsUnionPhrase, + includedPropsPhrase, + ); + return true; + } + } + return false; +} + +function propNamesListJoin( + list: Array, + combinator: 'and' | 'or', +): string { + switch (list.length) { + case 0: + return ''; + case 1: + return list[0]; + case 2: + return list[0] + ' ' + combinator + ' ' + list[1]; + default: + return ( + list.slice(0, -1).join(', ') + + ', ' + + combinator + + ' ' + + list[list.length - 1] + ); + } +} + +export function validateLinkPropsForPreloadResource(linkProps: any) { + if (__DEV__) { + const {href, as} = linkProps; + if (as === 'font') { + const name = getResourceNameForWarning('preload', linkProps, false); + if (!hasOwnProperty.call(linkProps, 'crossOrigin')) { + console.error( + 'A %s with href "%s" did not specify the crossOrigin prop. Font preloads must always use' + + ' anonymouse CORS mode. To fix add an empty string, "anonymous", or any other string' + + ' value except "use-credentials" for the crossOrigin prop of all font preloads.', + name, + href, + ); + } else if (linkProps.crossOrigin === 'use-credentials') { + console.error( + 'A %s with href "%s" specified a crossOrigin value of "use-credentials". Font preloads must always use' + + ' anonymouse CORS mode. To fix use an empty string, "anonymous", or any other string' + + ' value except "use-credentials" for the crossOrigin prop of all font preloads.', + name, + href, + ); + } + } + } +} + +export function validatePreloadArguments(href: mixed, options: mixed) { + if (__DEV__) { + if (!href || typeof href !== 'string') { + const typeOfArg = getValueDescriptorExpectingObjectForWarning(href); + console.error( + 'ReactDOM.preload() expected the first argument to be a string representing an href but found %s instead.', + typeOfArg, + ); + } else if (typeof options !== 'object' || options === null) { + const typeOfArg = getValueDescriptorExpectingObjectForWarning(options); + console.error( + 'ReactDOM.preload() expected the second argument to be an options argument containing at least an "as" property' + + ' specifying the Resource type. It found %s instead. The href for the preload call where this warning originated is "%s".', + typeOfArg, + href, + ); + } else { + const as = options.as; + switch (as) { + // Font specific validation of options + case 'font': { + if (options.crossOrigin === 'use-credentials') { + console.error( + 'ReactDOM.preload() was called with an "as" type of "font" and with a "crossOrigin" option of "use-credentials".' + + ' Fonts preloading must use crossOrigin "anonymous" to be functional. Please update your font preload to omit' + + ' the crossOrigin option or change it to any other value than "use-credentials" (Browsers default all other values' + + ' to anonymous mode). The href for the preload call where this warning originated is "%s"', + href, + ); + } + break; + } + case 'style': { + break; + } + + // We have an invalid as type and need to warn + default: { + const typeOfAs = getValueDescriptorExpectingEnumForWarning(as); + console.error( + 'ReactDOM.preload() expected a valid "as" type in the options (second) argument but found %s instead.' + + ' Please use one of the following valid values instead: %s. The href for the preload call where this' + + ' warning originated is "%s".', + typeOfAs, + '"style" and "font"', + href, + ); + } + } + } + } +} + +export function validatePreinitArguments(href: mixed, options: mixed) { + if (__DEV__) { + if (!href || typeof href !== 'string') { + const typeOfArg = getValueDescriptorExpectingObjectForWarning(href); + console.error( + 'ReactDOM.preinit() expected the first argument to be a string representing an href but found %s instead.', + typeOfArg, + ); + } else if (typeof options !== 'object' || options === null) { + const typeOfArg = getValueDescriptorExpectingObjectForWarning(options); + console.error( + 'ReactDOM.preinit() expected the second argument to be an options argument containing at least an "as" property' + + ' specifying the Resource type. It found %s instead. The href for the preload call where this warning originated is "%s".', + typeOfArg, + href, + ); + } else { + const as = options.as; + switch (as) { + case 'font': + case 'style': { + break; + } + + // We have an invalid as type and need to warn + default: { + const typeOfAs = getValueDescriptorExpectingEnumForWarning(as); + console.error( + 'ReactDOM.preinit() expected the second argument to be an options argument containing at least an "as" property' + + ' specifying the Resource type. It found %s instead. Currently, the only valid resource type for preinit is "style".' + + ' The href for the preinit call where this warning originated is "%s".', + typeOfAs, + href, + ); + } + } + } + } +} + +function getValueDescriptorExpectingObjectForWarning(thing: any): string { + return thing === null + ? 'null' + : thing === undefined + ? 'undefined' + : thing === '' + ? 'an empty string' + : `something with type "${typeof thing}"`; +} + +function getValueDescriptorExpectingEnumForWarning(thing: any): string { + return thing === null + ? 'null' + : thing === undefined + ? 'undefined' + : thing === '' + ? 'an empty string' + : typeof thing === 'string' + ? JSON.stringify(thing) + : `something with type "${typeof thing}"`; +} diff --git a/packages/react-dom/index.experimental.js b/packages/react-dom/index.experimental.js index 11cb1a262f4d5..e8f165a72a027 100644 --- a/packages/react-dom/index.experimental.js +++ b/packages/react-dom/index.experimental.js @@ -22,3 +22,5 @@ export { unstable_runWithPriority, // DO NOT USE: Temporarily exposed to migrate off of Scheduler.runWithPriority. version, } from './src/client/ReactDOM'; + +export {preinit, preload} from 'react-dom-bindings/src/shared/ReactDOMFloat'; diff --git a/packages/react-dom/src/ReactDOMSharedInternals.js b/packages/react-dom/src/ReactDOMSharedInternals.js index 9e678ea2a9604..3fb74360285ed 100644 --- a/packages/react-dom/src/ReactDOMSharedInternals.js +++ b/packages/react-dom/src/ReactDOMSharedInternals.js @@ -17,11 +17,10 @@ import { getNodeFromInstance, getFiberCurrentPropsFromNode, } from 'react-dom-bindings/src/client/ReactDOMComponentTree'; +import Dispatcher from 'react-dom-bindings/src/shared/ReactDOMDispatcher'; const Internals = { usingClientEntryPoint: false, - // Keep in sync with ReactTestUtils.js. - // This is an array for better minification. Events: [ getInstanceFromNode, getNodeFromInstance, @@ -30,6 +29,7 @@ const Internals = { restoreStateIfNeeded, batchedUpdates, ], + Dispatcher, }; export default Internals; diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index f89074a83124f..fec985bd20299 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -329,6 +329,7 @@ describe('ReactDOMFizzServer', () => { ); pipe(writable); }); + expect(getVisibleChildren(container)).toEqual(
Loading...
@@ -4431,267 +4432,6 @@ describe('ReactDOMFizzServer', () => { expect(chunks.pop()).toEqual(''); }); - // @gate enableFloat - it('recognizes stylesheet links as attributes during hydration', async () => { - await actIntoEmptyDocument(() => { - const {pipe} = ReactDOMFizzServer.renderToPipeableStream( - <> - - - - - - a body - - , - ); - pipe(writable); - }); - // precedence for stylesheets is mapped to a valid data attribute that is recognized on the client - // as opting this node into resource semantics. the use of precedence on the author link is just a - // non standard attribute which React allows but is not given any special treatment. - expect(getVisibleChildren(document)).toEqual( - - - - - - a body - , - ); - - // It hydrates successfully - const root = ReactDOMClient.hydrateRoot( - document, - <> - - - - - - a body - - , - ); - // We manually capture uncaught errors b/c Jest does not play well with errors thrown in - // microtasks after the test completes even when it is expecting to fail (e.g. when the gate is false) - // We need to flush the scheduler at the end even if there was an earlier throw otherwise this test will - // fail even when failure is expected. This is primarily caused by invokeGuardedCallback replaying commit - // phase errors which get rethrown in a microtask - const uncaughtErrors = []; - try { - expect(Scheduler).toFlushWithoutYielding(); - expect(getVisibleChildren(document)).toEqual( - - - - - - a body - , - ); - } catch (e) { - uncaughtErrors.push(e); - } - try { - expect(Scheduler).toFlushWithoutYielding(); - } catch (e) { - uncaughtErrors.push(e); - } - - root.render( - <> - - - - a body - - , - ); - try { - expect(Scheduler).toFlushWithoutYielding(); - expect(getVisibleChildren(document)).toEqual( - - - - - a body - , - ); - } catch (e) { - uncaughtErrors.push(e); - } - try { - expect(Scheduler).toFlushWithoutYielding(); - } catch (e) { - uncaughtErrors.push(e); - } - - if (uncaughtErrors.length > 0) { - throw uncaughtErrors[0]; - } - }); - - // Temporarily this test is expected to fail everywhere. When we have resource hoisting - // it should start to pass and we can adjust the gate accordingly - // @gate false && enableFloat - it('should insert missing resources during hydration', async () => { - await actIntoEmptyDocument(() => { - const {pipe} = ReactDOMFizzServer.renderToPipeableStream( - - foo - , - ); - pipe(writable); - }); - - const uncaughtErrors = []; - ReactDOMClient.hydrateRoot( - document, - <> - - - - foo - - , - ); - try { - expect(Scheduler).toFlushWithoutYielding(); - expect(getVisibleChildren(document)).toEqual( - - - - - foo - , - ); - } catch (e) { - uncaughtErrors.push(e); - } - - // need to flush again to get the invoke guarded callback error to throw in microtask - try { - expect(Scheduler).toFlushWithoutYielding(); - } catch (e) { - uncaughtErrors.push(e); - } - - if (uncaughtErrors.length) { - throw uncaughtErrors[0]; - } - }); - - // @gate experimental && enableFloat - it('fail hydration if a suitable resource cannot be found in the DOM for a given location (href)', async () => { - gate(flags => { - if (!(__EXPERIMENTAL__ && flags.enableFloat)) { - throw new Error('bailing out of test'); - } - }); - await actIntoEmptyDocument(() => { - const {pipe} = ReactDOMFizzServer.renderToPipeableStream( - - - a body - , - ); - pipe(writable); - }); - - const errors = []; - ReactDOMClient.hydrateRoot( - document, - - - - - a body - , - { - onRecoverableError(err, errInfo) { - errors.push(err.message); - }, - }, - ); - expect(() => { - expect(Scheduler).toFlushWithoutYielding(); - }).toErrorDev( - [ - 'Warning: A matching Hydratable Resource was not found in the DOM for ', - 'Warning: An error occurred during hydration. The server HTML was replaced with client content in <#document>.', - ], - {withoutStack: 1}, - ); - expect(errors).toEqual([ - 'Hydration failed because the initial UI does not match what was rendered on the server.', - 'Hydration failed because the initial UI does not match what was rendered on the server.', - 'There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.', - ]); - }); - - // @gate experimental && enableFloat - it('should error in dev when rendering more than one resource for a given location (href)', async () => { - gate(flags => { - if (!(__EXPERIMENTAL__ && flags.enableFloat)) { - throw new Error('bailing out of test'); - } - }); - await actIntoEmptyDocument(() => { - const {pipe} = ReactDOMFizzServer.renderToPipeableStream( - <> - - - - - a body - - , - ); - pipe(writable); - }); - expect(getVisibleChildren(document)).toEqual( - - - - - - a body - , - ); - - const errors = []; - ReactDOMClient.hydrateRoot( - document, - <> - - - - - - a body - - , - { - onRecoverableError(err, errInfo) { - errors.push(err.message); - }, - }, - ); - expect(() => { - expect(Scheduler).toFlushWithoutYielding(); - }).toErrorDev([ - 'Warning: Stylesheet resources need a unique representation in the DOM while hydrating and more than one matching DOM Node was found. To fix, ensure you are only rendering one stylesheet link with an href attribute of "foo"', - 'Warning: Stylesheet resources need a unique representation in the DOM while hydrating and more than one matching DOM Node was found. To fix, ensure you are only rendering one stylesheet link with an href attribute of "foo"', - ]); - expect(errors).toEqual([]); - }); - describe('text separators', () => { // To force performWork to start before resolving AsyncText but before piping we need to wait until // after scheduleWork which currently uses setImmediate to delay performWork diff --git a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js new file mode 100644 index 0000000000000..86273e90876de --- /dev/null +++ b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js @@ -0,0 +1,3473 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + */ + +'use strict'; + +let JSDOM; +let Stream; +let Scheduler; +let React; +let ReactDOM; +let ReactDOMClient; +let ReactDOMFizzServer; +let Suspense; +let textCache; +let document; +let writable; +const CSPnonce = null; +let container; +let buffer = ''; +let hasErrored = false; +let fatalError = undefined; + +describe('ReactDOMFloat', () => { + beforeEach(() => { + jest.resetModules(); + JSDOM = require('jsdom').JSDOM; + Scheduler = require('scheduler'); + React = require('react'); + ReactDOM = require('react-dom'); + ReactDOMClient = require('react-dom/client'); + ReactDOMFizzServer = require('react-dom/server'); + Stream = require('stream'); + Suspense = React.Suspense; + + textCache = new Map(); + + // Test Environment + const jsdom = new JSDOM( + '
', + { + runScripts: 'dangerously', + }, + ); + document = jsdom.window.document; + container = document.getElementById('container'); + + buffer = ''; + hasErrored = false; + + writable = new Stream.PassThrough(); + writable.setEncoding('utf8'); + writable.on('data', chunk => { + buffer += chunk; + }); + writable.on('error', error => { + hasErrored = true; + fatalError = error; + }); + }); + + function normalizeCodeLocInfo(str) { + return ( + typeof str === 'string' && + str.replace(/\n +(?:at|in) ([\S]+)[^\n]*/g, function(m, name) { + return '\n in ' + name + ' (at **)'; + }) + ); + } + + function componentStack(components) { + return components + .map(component => `\n in ${component} (at **)`) + .join(''); + } + + async function act(callback) { + await callback(); + // Await one turn around the event loop. + // This assumes that we'll flush everything we have so far. + await new Promise(resolve => { + setImmediate(resolve); + }); + if (hasErrored) { + throw fatalError; + } + // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment. + // We also want to execute any scripts that are embedded. + // We assume that we have now received a proper fragment of HTML. + const bufferedContent = buffer; + buffer = ''; + const fakeBody = document.createElement('body'); + fakeBody.innerHTML = bufferedContent; + const parent = + container.nodeName === '#document' ? container.body : container; + while (fakeBody.firstChild) { + const node = fakeBody.firstChild; + if ( + node.nodeName === 'SCRIPT' && + (CSPnonce === null || node.getAttribute('nonce') === CSPnonce) + ) { + const script = document.createElement('script'); + script.textContent = node.textContent; + fakeBody.removeChild(node); + parent.appendChild(script); + } else { + parent.appendChild(node); + } + } + } + + async function actIntoEmptyDocument(callback) { + await callback(); + // Await one turn around the event loop. + // This assumes that we'll flush everything we have so far. + await new Promise(resolve => { + setImmediate(resolve); + }); + if (hasErrored) { + throw fatalError; + } + // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment. + // We also want to execute any scripts that are embedded. + // We assume that we have now received a proper fragment of HTML. + const bufferedContent = buffer; + // Test Environment + const jsdom = new JSDOM(bufferedContent, { + runScripts: 'dangerously', + }); + document = jsdom.window.document; + container = document; + buffer = ''; + } + + function getVisibleChildren(element) { + const children = []; + let node = element.firstChild; + while (node) { + if (node.nodeType === 1) { + if ( + node.tagName !== 'SCRIPT' && + node.tagName !== 'TEMPLATE' && + node.tagName !== 'template' && + !node.hasAttribute('hidden') && + !node.hasAttribute('aria-hidden') + ) { + const props = {}; + const attributes = node.attributes; + for (let i = 0; i < attributes.length; i++) { + if ( + attributes[i].name === 'id' && + attributes[i].value.includes(':') + ) { + // We assume this is a React added ID that's a non-visual implementation detail. + continue; + } + props[attributes[i].name] = attributes[i].value; + } + props.children = getVisibleChildren(node); + children.push(React.createElement(node.tagName.toLowerCase(), props)); + } + } else if (node.nodeType === 3) { + children.push(node.data); + } + node = node.nextSibling; + } + return children.length === 0 + ? undefined + : children.length === 1 + ? children[0] + : children; + } + + function resolveText(text) { + const record = textCache.get(text); + if (record === undefined) { + const newRecord = { + status: 'resolved', + value: text, + }; + textCache.set(text, newRecord); + } else if (record.status === 'pending') { + const thenable = record.value; + record.status = 'resolved'; + record.value = text; + thenable.pings.forEach(t => t()); + } + } + + function readText(text) { + const record = textCache.get(text); + if (record !== undefined) { + switch (record.status) { + case 'pending': + throw record.value; + case 'rejected': + throw record.value; + case 'resolved': + return record.value; + } + } else { + const thenable = { + pings: [], + then(resolve) { + if (newRecord.status === 'pending') { + thenable.pings.push(resolve); + } else { + Promise.resolve().then(() => resolve(newRecord.value)); + } + }, + }; + + const newRecord = { + status: 'pending', + value: thenable, + }; + textCache.set(text, newRecord); + + throw thenable; + } + } + + function AsyncText({text}) { + return readText(text); + } + + it('errors if the document does not contain a head when inserting a resource', async () => { + document.head.parentNode.removeChild(document.head); + const root = ReactDOMClient.createRoot(document); + root.render( + + + + foo + + , + ); + expect(() => { + expect(Scheduler).toFlushWithoutYielding(); + }).toThrow( + 'While attempting to insert a Resource, React expected the Document to contain a head element but it was not found.', + ); + }); + + describe('HostResource', () => { + // @gate enableFloat + it('warns when you update props to an invalid type', async () => { + const root = ReactDOMClient.createRoot(container); + root.render( +
+ +
, + ); + expect(Scheduler).toFlushWithoutYielding(); + root.render( +
+ +
, + ); + expect(() => { + expect(Scheduler).toFlushWithoutYielding(); + }).toErrorDev( + 'Warning: A previously rendered as a Resource with href "foo" but was updated with a rel type that is not' + + ' valid for a Resource type. Generally Resources are not expected to ever have updated' + + ' props however in some limited circumstances it can be valid when changing the href.' + + ' When React encounters props that invalidate the Resource it is the same as not rendering' + + ' a Resource at all. valid rel types for Resources are "font" and "style". The previous' + + ' rel for this instance was "stylesheet". The updated rel is "author" and the updated href is "bar".', + ); + expect(getVisibleChildren(document)).toEqual( + + + + + + +
+
+
+ + , + ); + }); + }); + + describe('ReactDOM.preload', () => { + // @gate enableFloat + it('inserts a preload resource into the stream when called during server rendering', async () => { + function Component() { + ReactDOM.preload('foo', {as: 'style'}); + return 'foo'; + } + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + + + + , + ); + pipe(writable); + }); + expect(getVisibleChildren(document)).toEqual( + + + + + foo + , + ); + }); + + // @gate enableFloat + it('inserts a preload resource into the document during render when called during client rendering', async () => { + function Component() { + ReactDOM.preload('foo', {as: 'style'}); + return 'foo'; + } + const root = ReactDOMClient.createRoot(container); + root.render(); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + + + + + +
foo
+ + , + ); + }); + + // @gate enableFloat + it('inserts a preload resource when called in a layout effect', async () => { + function App() { + React.useLayoutEffect(() => { + ReactDOM.preload('foo', {as: 'style'}); + }, []); + return 'foobar'; + } + const root = ReactDOMClient.createRoot(container); + root.render(); + expect(Scheduler).toFlushWithoutYielding(); + + expect(getVisibleChildren(document)).toEqual( + + + + + +
foobar
+ + , + ); + }); + + // @gate enableFloat + it('inserts a preload resource when called in a passive effect', async () => { + function App() { + React.useEffect(() => { + ReactDOM.preload('foo', {as: 'style'}); + }, []); + return 'foobar'; + } + const root = ReactDOMClient.createRoot(container); + root.render(); + expect(Scheduler).toFlushWithoutYielding(); + + expect(getVisibleChildren(document)).toEqual( + + + + + +
foobar
+ + , + ); + }); + + // @gate enableFloat + it('inserts a preload resource when called in module scope', async () => { + ReactDOM.preload('foo', {as: 'style'}); + // We need to use global.document because preload falls back + // to the window.document global when no other documents have been used + // The way the JSDOM runtim is created for these tests the local document + // global does not point to the global.document + expect(getVisibleChildren(global.document)).toEqual( + + + + + + , + ); + }); + }); + + describe('ReactDOM.preinit as style', () => { + // @gate enableFloat + it('creates a style Resource when called during server rendering before first flush', async () => { + function Component() { + ReactDOM.preinit('foo', {as: 'style', precedence: 'foo'}); + return 'foo'; + } + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + + + + , + ); + pipe(writable); + }); + expect(getVisibleChildren(document)).toEqual( + + + + + foo + , + ); + }); + + // @gate enableFloat + it('creates a preload Resource when called during server rendering after first flush', async () => { + function BlockedOn({text, children}) { + readText(text); + return children; + } + function Component() { + ReactDOM.preinit('foo', {as: 'style', precedence: 'foo'}); + return 'foo'; + } + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + + + + + + + + , + ); + pipe(writable); + }); + await act(() => { + resolveText('unblock'); + }); + expect(getVisibleChildren(document)).toEqual( + + + + foo + + + , + ); + }); + + // @gate enableFloat + it('inserts a style Resource into the document during render when called during client rendering', async () => { + function Component() { + ReactDOM.preinit('foo', {as: 'style', precedence: 'foo'}); + return 'foo'; + } + const root = ReactDOMClient.createRoot(container); + root.render(); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + + + + + +
foo
+ + , + ); + }); + + // @gate enableFloat + it('inserts a preload resource into the document when called in an insertion effect, layout effect, or passive effect', async () => { + function App() { + React.useEffect(() => { + ReactDOM.preinit('passive', {as: 'style', precedence: 'default'}); + }, []); + React.useLayoutEffect(() => { + ReactDOM.preinit('layout', {as: 'style', precedence: 'default'}); + }); + React.useInsertionEffect(() => { + ReactDOM.preinit('insertion', {as: 'style', precedence: 'default'}); + }); + return 'foobar'; + } + const root = ReactDOMClient.createRoot(container); + root.render(); + expect(Scheduler).toFlushWithoutYielding(); + + expect(getVisibleChildren(document)).toEqual( + + + + + + + +
foobar
+ + , + ); + }); + + // @gate enableFloat + it('inserts a preload resource when called in module scope', async () => { + ReactDOM.preinit('foo', {as: 'style'}); + // We need to use global.document because preload falls back + // to the window.document global when no other documents have been used + // The way the JSDOM runtim is created for these tests the local document + // global does not point to the global.document + expect(getVisibleChildren(global.document)).toEqual( + + + + + + , + ); + }); + }); + + describe('style resources', () => { + // @gate enableFloat + it('treats link rel stylesheet elements as a style resource when it includes a precedence when server rendering', async () => { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + + +
hello world
+ + , + ); + pipe(writable); + }); + + expect(getVisibleChildren(document)).toEqual( + + + + + +
hello world
+ + , + ); + }); + + // @gate enableFloat + it('treats link rel stylesheet elements as a style resource when it includes a precedence when client rendering', async () => { + const root = ReactDOMClient.createRoot(document); + root.render( + + + + +
hello world
+ + , + ); + expect(Scheduler).toFlushWithoutYielding(); + + expect(getVisibleChildren(document)).toEqual( + + + + + +
hello world
+ + , + ); + }); + + // @gate enableFloat + it('treats link rel stylesheet elements as a style resource when it includes a precedence when hydrating', async () => { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + + +
hello world
+ + , + ); + pipe(writable); + }); + ReactDOMClient.hydrateRoot( + document, + + + + +
hello world
+ + , + ); + expect(Scheduler).toFlushWithoutYielding(); + + expect(getVisibleChildren(document)).toEqual( + + + + + +
hello world
+ + , + ); + }); + + // @gate enableFloat + it('preloads stylesheets without a precedence prop when server rendering', async () => { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + + +
hello world
+ + , + ); + pipe(writable); + }); + + expect(getVisibleChildren(document)).toEqual( + + + + + + +
hello world
+ + , + ); + }); + + // @gate enableFloat + it('hoists style resources to the correct precedence', async () => { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + + + + +
hello world
+ + , + ); + pipe(writable); + }); + expect(getVisibleChildren(document)).toEqual( + + + + + + + +
hello world
+ + , + ); + + ReactDOMClient.hydrateRoot( + document, + + + + + + +
hello world
+ + , + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + + + + + + + + + + + + + +
hello world
+ + , + ); + }); + + // @gate enableFloat + it('retains styles even after the last referring Resource unmounts', async () => { + // This test is true until a future update where there is some form of garbage collection. + const root = ReactDOMClient.createRoot(document); + + root.render( + + + + hello world + + + , + ); + expect(Scheduler).toFlushWithoutYielding(); + + root.render( + + + hello world + , + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + + + + + hello world + , + ); + }); + + // @gate enableFloat + it('retains styles even when a new html, head, and/body mount', async () => { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + + + + server + + , + ); + pipe(writable); + }); + const errors = []; + ReactDOMClient.hydrateRoot( + document, + + + + + + client + , + { + onRecoverableError(error) { + errors.push(error.message); + }, + }, + ); + expect(() => { + expect(Scheduler).toFlushWithoutYielding(); + }).toErrorDev( + [ + 'Warning: Text content did not match. Server: "server" Client: "client"', + 'Warning: An error occurred during hydration. The server HTML was replaced with client content in <#document>.', + ], + {withoutStack: 1}, + ); + expect(getVisibleChildren(document)).toEqual( + + + + + + client + , + ); + }); + + // @gate enableFloat + it('retains styles in head through head remounts', async () => { + const root = ReactDOMClient.createRoot(document); + root.render( + + + + + + hello + + , + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + + + + + + hello + , + ); + + root.render( + + + + + + hello + + , + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + + + + + + hello + , + ); + }); + }); + + // @gate enableFloat + it('client renders a boundary if a style Resource dependency fails to load', async () => { + function BlockedOn({text, children}) { + readText(text); + return children; + } + function App() { + return ( + + + + + + + + Hello + + + + + ); + } + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + + await act(() => { + resolveText('unblock'); + }); + + expect(getVisibleChildren(document)).toEqual( + + + + + + + loading... + + + + , + ); + + await act(() => { + const barLink = document.querySelector( + 'link[rel="stylesheet"][href="bar"]', + ); + const event = document.createEvent('Events'); + event.initEvent('error', true, true); + barLink.dispatchEvent(event); + }); + + const boundaryTemplateInstance = document.getElementById('B:0'); + const suspenseInstance = boundaryTemplateInstance.previousSibling; + + expect(suspenseInstance.data).toEqual('$!'); + expect(boundaryTemplateInstance.dataset.dgst).toBe( + 'Resource failed to load', + ); + + expect(getVisibleChildren(document)).toEqual( + + + + + + + loading... + + + + , + ); + + const errors = []; + ReactDOMClient.hydrateRoot(document, , { + onRecoverableError(err, errInfo) { + errors.push(err.message); + errors.push(err.digest); + }, + }); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + + + + + + + + + Hello + + , + ); + expect(errors).toEqual([ + 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.', + 'Resource failed to load', + ]); + }); + + // @gate enableFloat + it('treats stylesheet links with a precedence as a resource', async () => { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + + + Hello + + , + ); + pipe(writable); + }); + expect(getVisibleChildren(document)).toEqual( + + + + + Hello + , + ); + + ReactDOMClient.hydrateRoot( + document, + + + Hello + , + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + + + + + Hello + , + ); + }); + + // @gate enableFloat + it('inserts text separators following text when followed by an element that is converted to a resource and thus removed from the html inline', async () => { + // If you render many of these as siblings the values get emitted as a single text with no separator sometimes + // because the link gets elided as a resource + function AsyncTextWithResource({text, href, precedence}) { + const value = readText(text); + return ( + <> + {value} + + + ); + } + + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + + + + + + , + ); + pipe(writable); + resolveText('foo'); + resolveText('bar'); + resolveText('baz'); + }); + + expect(getVisibleChildren(document)).toEqual( + + + + + + + + {'foo'} + {'bar'} + {'baz'} + + , + ); + }); + + // @gate enableFloat + it('hoists late stylesheets the correct precedence', async () => { + function AsyncListItemWithResource({text, href, precedence, ...rest}) { + const value = readText(text); + return ( +
  • + + {value} +
  • + ); + } + function BlockingChildren({text, children}) { + readText(text); + return children; + } + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + + +
    + + +
      +
    • + +
    • + +
    +
    +
    +
    + +
      + + + +
    +
    +
    +
    + + +
      + + + +
    +
    +
    +
    + + , + ); + pipe(writable); + }); + + expect(getVisibleChildren(document)).toEqual( + + + + + + +
    loading foo bar...
    +
    loading bar baz qux...
    +
    loading bar baz qux...
    + + , + ); + + await act(() => { + resolveText('foo'); + resolveText('bar'); + }); + + expect(getVisibleChildren(document)).toEqual( + + + + + + + + +
    loading foo bar...
    +
    loading bar baz qux...
    +
    loading bar baz qux...
    + + + , + ); + + await act(() => { + const link = document.querySelector('link[rel="stylesheet"][href="foo"]'); + const event = document.createEvent('Events'); + event.initEvent('load', true, true); + link.dispatchEvent(event); + }); + + expect(getVisibleChildren(document)).toEqual( + + + + + + + + +
    loading foo bar...
    +
    loading bar baz qux...
    +
    loading bar baz qux...
    + + + , + ); + + await act(() => { + const link = document.querySelector('link[rel="stylesheet"][href="bar"]'); + const event = document.createEvent('Events'); + event.initEvent('load', true, true); + link.dispatchEvent(event); + }); + + expect(getVisibleChildren(document)).toEqual( + + + + + + + + +
    +
      +
    • foo
    • +
    • bar
    • +
    +
    +
    loading bar baz qux...
    +
    loading bar baz qux...
    + + + , + ); + + await act(() => { + resolveText('baz'); + }); + + expect(getVisibleChildren(document)).toEqual( + + + + + + + + +
    +
      +
    • foo
    • +
    • bar
    • +
    +
    +
    loading bar baz qux...
    +
    loading bar baz qux...
    + + + + , + ); + + await act(() => { + resolveText('qux'); + }); + + expect(getVisibleChildren(document)).toEqual( + + + + + + + + + + +
    +
      +
    • foo
    • +
    • bar
    • +
    +
    +
    loading bar baz qux...
    +
    loading bar baz qux...
    + + + + + , + ); + + await act(() => { + const bazlink = document.querySelector( + 'link[rel="stylesheet"][href="baz"]', + ); + const quxlink = document.querySelector( + 'link[rel="stylesheet"][href="qux"]', + ); + const event = document.createEvent('Events'); + event.initEvent('load', true, true); + bazlink.dispatchEvent(event); + quxlink.dispatchEvent(event); + }); + + expect(getVisibleChildren(document)).toEqual( + + + + + + + + + + +
    +
      +
    • foo
    • +
    • bar
    • +
    +
    +
    +
      +
    • bar
    • +
    • baz
    • +
    • qux
    • +
    +
    +
    loading bar baz qux...
    + + + + + , + ); + + await act(() => { + resolveText('unblock'); + }); + + expect(getVisibleChildren(document)).toEqual( + + + + + + + + + + +
    +
      +
    • foo
    • +
    • bar
    • +
    +
    +
    +
      +
    • bar
    • +
    • baz
    • +
    • qux
    • +
    +
    +
    +
      +
    • bar
    • +
    • baz
    • +
    • qux
    • +
    +
    + + + + + , + ); + }); + + // @gate enableFloat + it('normalizes style resource precedence for all boundaries inlined as part of the shell flush', async () => { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + +
    + outer + + + + + +
    + middle + + + + + +
    + inner + + + + + +
    +
    +
    +
    + +
    middle
    + + + + +
    +
    + + , + ); + pipe(writable); + }); + + // The reason the href's aren't ordered linearly is that when boundaries complete their resources + // get hoisted to the shell directly so they can flush in the head. If a boundary doesn't suspend then + // child boundaries will complete before the parent boundary and thus have their resources hoist + // early. The reason precedences are still ordered correctly between child and parent is because + // the precedence ordering is determined upon first discovernig a resource rather than on hoist and + // so it follows render order + expect(getVisibleChildren(document)).toEqual( + + + + + + + + + + + + + + + + + + + + + + + + + +
    + outer +
    + middle
    inner
    +
    +
    middle
    +
    + + , + ); + }); + + // @gate enableFloat + it('style resources are inserted according to precedence order on the client', async () => { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + +
    + + + Hello +
    + + , + ); + pipe(writable); + }); + + expect(getVisibleChildren(document)).toEqual( + + + + + + +
    Hello
    + + , + ); + + const root = ReactDOMClient.hydrateRoot( + document, + + + +
    + + + Hello +
    + + , + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + + + + + + +
    Hello
    + + , + ); + + root.render( + + + +
    Hello
    + + + , + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + + + + + + + + +
    Hello
    + + , + ); + }); + + // @gate enableFloat + it('inserts preloads in render phase eagerly', async () => { + function Throw() { + throw new Error('Uh oh!'); + } + class ErrorBoundary extends React.Component { + state = {hasError: false, error: null}; + static getDerivedStateFromError(error) { + return { + hasError: true, + error, + }; + } + render() { + if (this.state.hasError) { + return this.state.error.message; + } + return this.props.children; + } + } + + const root = ReactDOMClient.createRoot(container); + root.render( + + +
    foo
    + +
    , + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + + + + + +
    Uh oh!
    + + , + ); + }); + + // @gate enableFloat + it('does not emit preinit stylesheets if they are invoked after the shell flushes', async () => { + function PreinitsBlockedOn({text}) { + readText(text); + ReactDOM.preinit('one', {precedence: 'one', as: 'style'}); + ReactDOM.preinit('two', {precedence: 'two', as: 'style'}); + return null; + } + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + +
    + + + Hello +
    +
    + + + + +
    + + , + ); + pipe(writable); + }); + + expect(getVisibleChildren(document)).toEqual( + + + + + + +
    Hello
    +
    loading...
    + + , + ); + + await act(() => { + resolveText('foo'); + }); + expect(getVisibleChildren(document)).toEqual( + + + + + + +
    Hello
    +
    loading...
    + + + + , + ); + + await act(() => { + resolveText('bar'); + }); + expect(getVisibleChildren(document)).toEqual( + + + + + + +
    Hello
    +
    bar
    + + + + , + ); + }); + + // @gate enableFloat + it('will include child boundary style resources in the boundary reveal instruction', async () => { + function BlockedOn({text, children}) { + readText(text); + return children; + } + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + +
    + + +
    foo
    + + + +
    bar
    + + + +
    baz
    + +
    +
    +
    +
    +
    +
    +
    + + , + ); + pipe(writable); + }); + + expect(getVisibleChildren(document)).toEqual( + + + +
    loading foo...
    + + , + ); + + await act(() => { + resolveText('bar'); + }); + expect(getVisibleChildren(document)).toEqual( + + + +
    loading foo...
    + + , + ); + + await act(() => { + resolveText('baz'); + }); + expect(getVisibleChildren(document)).toEqual( + + + +
    loading foo...
    + + , + ); + + await act(() => { + resolveText('foo'); + }); + expect(getVisibleChildren(document)).toEqual( + + + + + + + +
    loading foo...
    + + + + + , + ); + + await act(() => { + const event = document.createEvent('Events'); + event.initEvent('load', true, true); + Array.from(document.querySelectorAll('link[rel="stylesheet"]')).forEach( + el => { + el.dispatchEvent(event); + }, + ); + }); + expect(getVisibleChildren(document)).toEqual( + + + + + + + +
    +
    foo
    +
    bar
    +
    baz
    +
    + + + + + , + ); + }); + + // @gate enableFloat + it('will hoist resources of child boundaries emitted as part of a partial boundary to the parent boundary', async () => { + function BlockedOn({text, children}) { + readText(text); + return children; + } + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + +
    + +
    + +
    foo
    + + + +
    bar
    + + +
    + +
    baz
    + +
    +
    +
    +
    +
    +
    + +
    qux
    + +
    +
    +
    +
    + + , + ); + pipe(writable); + }); + + expect(getVisibleChildren(document)).toEqual( + + + +
    loading...
    + + , + ); + + // This will enqueue a style resource in a deep blocked boundary (loading baz...). + await act(() => { + resolveText('baz'); + }); + expect(getVisibleChildren(document)).toEqual( + + + +
    loading...
    + + , + ); + + // This will enqueue a style resource in the intermediate blocked boundary (loading bar...). + await act(() => { + resolveText('bar'); + }); + expect(getVisibleChildren(document)).toEqual( + + + +
    loading...
    + + , + ); + + // This will complete a segment in the top level boundary that is still blocked on another segment. + // It will flush the completed segment however the inner boundaries should not emit their style dependencies + // because they are not going to be revealed yet. instead their dependencies are hoisted to the blocked + // boundary (top level). + await act(() => { + resolveText('foo'); + }); + expect(getVisibleChildren(document)).toEqual( + + + +
    loading...
    + + + + + , + ); + + // This resolves the last blocked segment on the top level boundary so we see all dependencies of the + // nested boundaries emitted at this level + await act(() => { + resolveText('qux'); + }); + expect(getVisibleChildren(document)).toEqual( + + + + + + + + +
    loading...
    + + + + + + , + ); + + // We load all stylesheets and confirm the content is revealed + await act(() => { + const event = document.createEvent('Events'); + event.initEvent('load', true, true); + Array.from(document.querySelectorAll('link[rel="stylesheet"]')).forEach( + el => { + el.dispatchEvent(event); + }, + ); + }); + expect(getVisibleChildren(document)).toEqual( + + + + + + + + +
    +
    +
    foo
    +
    bar
    +
    +
    baz
    +
    +
    qux
    +
    +
    + + + + + + , + ); + }); + + // @gate enableFloat + it('encodes attributes consistently whether resources are flushed in shell or in late boundaries', async () => { + const originalConsoleError = console.error; + const mockError = jest.fn(); + console.error = (...args) => { + mockError(...args.map(normalizeCodeLocInfo)); + }; + function BlockedOn({text, children}) { + readText(text); + return children; + } + function App() { + return ( + + + +
    + {}} + norsymbols={Symbol('foo')} + /> + + + {}} + norsymbols={Symbol('foo')} + /> + + +
    + + + ); + } + try { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + expect(getVisibleChildren(document)).toEqual( + + + + + +
    loading...
    + + , + ); + if (__DEV__) { + expect(mockError).toHaveBeenCalledTimes(2); + expect(mockError).toHaveBeenCalledWith( + 'Warning: React does not recognize the `%s` prop on a DOM element.' + + ' If you intentionally want it to appear in the DOM as a custom attribute,' + + ' spell it as lowercase `%s` instead. If you accidentally passed it from a' + + ' parent component, remove it from the DOM element.%s', + 'nonStandardAttr', + 'nonstandardattr', + componentStack(['link', 'div', 'body', 'html', 'App']), + ); + expect(mockError).toHaveBeenCalledWith( + 'Warning: Invalid values for props %s on <%s> tag. Either remove them from' + + ' the element, or pass a string or number value to keep them in the DOM. For' + + ' details, see https://reactjs.org/link/attribute-behavior %s', + '`shouldnotincludefunctions`, `norsymbols`', + 'link', + componentStack(['link', 'div', 'body', 'html', 'App']), + ); + mockError.mockClear(); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + + // Now we flush the stylesheet with the boundary + await act(() => { + resolveText('unblock'); + }); + + expect(getVisibleChildren(document)).toEqual( + + + + + + +
    loading...
    + + , + ); + if (__DEV__) { + // The way the test is currently set up the props that would warn have already warned + // so no new warnings appear. This is really testing the same code pathway so + // exercising that more here isn't all that useful + expect(mockError).toHaveBeenCalledTimes(0); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + } finally { + console.error = originalConsoleError; + } + }); + + // @gate enableFloat + it('boundary style resource dependencies hoist to a parent boundary when flushed inline', async () => { + function BlockedOn({text, children}) { + readText(text); + return children; + } + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + +
    + + + + + + + + + + + + + + + + + + +
    + + , + ); + pipe(writable); + }); + expect(getVisibleChildren(document)).toEqual( + + + +
    loading A...
    + + , + ); + + await act(() => { + resolveText('unblock'); + resolveText('AAAA'); + resolveText('AA'); + }); + expect(getVisibleChildren(document)).toEqual( + + + +
    loading A...
    + + + + + + , + ); + + await act(() => { + resolveText('A'); + }); + await act(() => { + document.querySelectorAll('link[rel="stylesheet"]').forEach(l => { + const event = document.createEvent('Events'); + event.initEvent('load', true, true); + l.dispatchEvent(event); + }); + }); + expect(getVisibleChildren(document)).toEqual( + + + + + + +
    + {'A'} + {'AA'} + {'loading AAA...'} +
    + + + + + + , + ); + + await act(() => { + resolveText('AAA'); + }); + await act(() => { + document.querySelectorAll('link[rel="stylesheet"]').forEach(l => { + const event = document.createEvent('Events'); + event.initEvent('load', true, true); + l.dispatchEvent(event); + }); + }); + expect(getVisibleChildren(document)).toEqual( + + + + + + + + +
    + {'A'} + {'AA'} + {'AAA'} + {'AAAA'} +
    + + + + + + , + ); + }); + + // @gate enableFloat + it('always enforces crossOrigin "anonymous" for font preloads', async () => { + const originalConsoleError = console.error; + const mockError = jest.fn(); + console.error = (...args) => { + mockError(...args.map(normalizeCodeLocInfo)); + }; + try { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + + + + + + + , + ); + pipe(writable); + }); + expect(getVisibleChildren(document)).toEqual( + + + + + + + + + , + ); + + if (__DEV__) { + expect(mockError).toHaveBeenCalledTimes(2); + expect(mockError).toHaveBeenCalledWith( + 'Warning: A %s with href "%s" did not specify the crossOrigin prop. Font preloads must always use' + + ' anonymouse CORS mode. To fix add an empty string, "anonymous", or any other string' + + ' value except "use-credentials" for the crossOrigin prop of all font preloads.%s', + 'preload Resource (as "font")', + 'foo', + componentStack(['link', 'body', 'html']), + ); + expect(mockError).toHaveBeenCalledWith( + 'Warning: A %s with href "%s" specified a crossOrigin value of "use-credentials". Font preloads must always use' + + ' anonymouse CORS mode. To fix use an empty string, "anonymous", or any other string' + + ' value except "use-credentials" for the crossOrigin prop of all font preloads.%s', + 'preload Resource (as "font")', + 'baz', + componentStack(['link', 'body', 'html']), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + } finally { + console.error = originalConsoleError; + } + }); + + describe('ReactDOM.pre* function validation', () => { + function Preloads({scenarios}) { + for (let i = 0; i < scenarios.length; i++) { + const href = scenarios[i][0]; + const options = scenarios[i][1]; + ReactDOM.preload(href, options); + } + } + function Preinits({scenarios}) { + for (let i = 0; i < scenarios.length; i++) { + const href = scenarios[i][0]; + const options = scenarios[i][1]; + ReactDOM.preinit(href, options); + } + } + async function renderOnServer(Component, scenarios) { + const originalConsoleError = console.error; + const mockError = jest.fn(); + console.error = (...args) => { + mockError(...args.map(normalizeCodeLocInfo)); + }; + try { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + + + , + ); + pipe(writable); + }); + for (let i = 0; i < scenarios.length; i++) { + const assertion = scenarios[i][2]; + assertion(mockError, i); + } + } finally { + console.error = originalConsoleError; + } + } + async function renderOnClient(Component, scenarios) { + const originalConsoleError = console.error; + const mockError = jest.fn(); + console.error = (...args) => { + mockError(...args.map(normalizeCodeLocInfo)); + }; + try { + const root = ReactDOMClient.createRoot(document); + root.render( + + + + + , + ); + expect(Scheduler).toFlushWithoutYielding(); + for (let i = 0; i < scenarios.length; i++) { + const assertion = scenarios[i][2]; + assertion(mockError, i); + } + } finally { + console.error = originalConsoleError; + } + } + + [ + ['server', renderOnServer], + ['client', renderOnClient], + ].forEach(([environment, render]) => { + // @gate enableFloat + it( + 'warns when an invalid href argument is provided to ReactDOM.preload on the ' + + environment, + async () => { + const expectedMessage = + 'Warning: ReactDOM.preload() expected the first argument to be a string representing an href but found %s instead.%s'; + const expectedStack = componentStack(['Preloads', 'head', 'html']); + function makeArgs(...substitutions) { + return [expectedMessage, ...substitutions, expectedStack]; + } + await render(Preloads, [ + [ + '', + undefined, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs('an empty string'), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + [ + undefined, + undefined, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs('undefined'), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + [ + null, + undefined, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs('null'), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + [ + 232132, + undefined, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs('something with type "number"'), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + [ + {}, + undefined, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs('something with type "object"'), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + ]); + }, + ); + + // @gate enableFloat + it( + 'warns when an invalid href argument is provided to ReactDOM.preinit on the ' + + environment, + async () => { + const expectedMessage = + 'Warning: ReactDOM.preinit() expected the first argument to be a string representing an href but found %s instead.%s'; + const expectedStack = componentStack(['Preinits', 'head', 'html']); + function makeArgs(...substitutions) { + return [expectedMessage, ...substitutions, expectedStack]; + } + await render(Preinits, [ + [ + '', + undefined, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs('an empty string'), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + [ + undefined, + undefined, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs('undefined'), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + [ + null, + undefined, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs('null'), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + [ + 232132, + undefined, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs('something with type "number"'), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + [ + {}, + undefined, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs('something with type "object"'), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + ]); + }, + ); + + // @gate enableFloat + it( + 'warns when an invalid options argument is provided to ReactDOM.preload on the ' + + environment, + async () => { + const expectedMessage = + 'Warning: ReactDOM.preload() expected the second argument to be an options argument containing at least an "as" property' + + ' specifying the Resource type. It found %s instead. The href for the preload call where this warning originated is "%s".%s'; + const expectedStack = componentStack(['Preloads', 'head', 'html']); + function makeArgs(...substitutions) { + return [expectedMessage, ...substitutions, expectedStack]; + } + await render(Preloads, [ + [ + 'foo', + undefined, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs('undefined', 'foo'), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + [ + 'foo', + null, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs('null', 'foo'), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + [ + 'foo', + 'bar', + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs('something with type "string"', 'foo'), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + [ + 'foo', + 123, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs('something with type "number"', 'foo'), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + ]); + }, + ); + + // @gate enableFloat + it( + 'warns when an invalid options argument is provided to ReactDOM.preinit on the ' + + environment, + async () => { + const expectedMessage = + 'Warning: ReactDOM.preinit() expected the second argument to be an options argument containing at least an "as" property' + + ' specifying the Resource type. It found %s instead. The href for the preload call where this warning originated is "%s".%s'; + const expectedStack = componentStack(['Preinits', 'head', 'html']); + function makeArgs(...substitutions) { + return [expectedMessage, ...substitutions, expectedStack]; + } + await render(Preinits, [ + [ + 'foo', + undefined, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs('undefined', 'foo'), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + [ + 'foo', + null, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs('null', 'foo'), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + [ + 'foo', + 'bar', + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs('something with type "string"', 'foo'), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + [ + 'foo', + 123, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs('something with type "number"', 'foo'), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + ]); + }, + ); + + // @gate enableFloat + it( + 'warns when an invalid "as" option is provided to ReactDOM.preload on the ' + + environment, + async () => { + const expectedMessage = + 'Warning: ReactDOM.preload() expected a valid "as" type in the options (second) argument but found %s instead.' + + ' Please use one of the following valid values instead: %s. The href for the preload call where this' + + ' warning originated is "%s".%s'; + const expectedStack = componentStack(['Preloads', 'head', 'html']); + function makeArgs(...substitutions) { + return [expectedMessage, ...substitutions, expectedStack]; + } + await render(Preloads, [ + [ + 'foo', + {}, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs('undefined', '"style" and "font"', 'foo'), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + [ + 'bar', + {as: null}, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs('null', '"style" and "font"', 'bar'), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + [ + 'baz', + {as: 123}, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs( + 'something with type "number"', + '"style" and "font"', + 'baz', + ), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + [ + 'qux', + {as: {}}, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs( + 'something with type "object"', + '"style" and "font"', + 'qux', + ), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + [ + 'quux', + {as: 'bar'}, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs('"bar"', '"style" and "font"', 'quux'), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + ]); + }, + ); + + // @gate enableFloat + it( + 'warns when an invalid "as" option is provided to ReactDOM.preinit on the ' + + environment, + async () => { + const expectedMessage = + 'Warning: ReactDOM.preinit() expected the second argument to be an options argument containing at least an "as" property' + + ' specifying the Resource type. It found %s instead. Currently, the only valid resource type for preinit is "style".' + + ' The href for the preinit call where this warning originated is "%s".%s'; + const expectedStack = componentStack(['Preinits', 'head', 'html']); + function makeArgs(...substitutions) { + return [expectedMessage, ...substitutions, expectedStack]; + } + await render(Preinits, [ + [ + 'foo', + {}, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs('undefined', 'foo'), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + [ + 'bar', + {as: null}, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs('null', 'bar'), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + [ + 'baz', + {as: 123}, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs('something with type "number"', 'baz'), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + [ + 'qux', + {as: {}}, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs('something with type "object"', 'qux'), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + [ + 'quux', + {as: 'bar'}, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs('"bar"', 'quux'), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + ]); + }, + ); + }); + }); + + describe('prop validation', () => { + // @gate enableFloat + it('warns when you change props on a resource unless you also change the href', async () => { + const root = ReactDOMClient.createRoot(container); + root.render( +
    + + + hello +
    , + ); + expect(Scheduler).toFlushWithoutYielding(); + + root.render( +
    + + + hello +
    , + ); + expect(() => { + expect(Scheduler).toFlushWithoutYielding(); + }).toErrorDev( + 'Warning: A style Resource with href "foo" recieved new props with different values from the props used' + + ' when this Resource was first rendered. React will only use the props provided when' + + ' this resource was first rendered until a new href is provided. Unlike conventional' + + ' DOM elements, Resources instances do not have a one to one correspondence with Elements' + + ' in the DOM and as such, every instance of a Resource for a single Resource identifier' + + ' (href) must have props that agree with each other. The differences are described below.' + + '\n data-something-extra: missing or null in latest props, "extra" in original props' + + '\n data-something-new: "new" in latest props, missing or null in original props' + + '\n precedence: "fu" in latest props, "foo" in original props', + ); + expect(getVisibleChildren(document)).toEqual( + + + + + + + + + + +
    +
    hello
    +
    + + , + ); + }); + + // @gate enableFloat + it('warns when style Resource have different values for media for the same href', async () => { + const originalConsoleError = console.error; + const mockError = jest.fn(); + console.error = (...args) => { + mockError(...args.map(normalizeCodeLocInfo)); + }; + try { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + + + + + + + + + + , + ); + pipe(writable); + }); + expect(getVisibleChildren(document)).toEqual( + + + + + + + + + , + ); + + if (__DEV__) { + expect(mockError).toHaveBeenCalledTimes(3); + expect(mockError).toHaveBeenCalledWith( + 'Warning: A %s with href "%s" has props that disagree with those found on %s. Resources always use the props' + + ' that were provided the first time they are encountered so any differences will be ignored. Please' + + ' update Resources that share an href to have props that agree. The differences are described below.%s%s', + 'style Resource', + 'foo', + 'an earlier instance of this Resource', + '\n media: missing or null in latest props, "all" in original props', + componentStack(['link', 'head', 'html']), + ); + expect(mockError).toHaveBeenCalledWith( + 'Warning: A %s with href "%s" has props that disagree with those found on %s. Resources always use the props' + + ' that were provided the first time they are encountered so any differences will be ignored. Please' + + ' update Resources that share an href to have props that agree. The differences are described below.%s%s', + 'style Resource', + 'bar', + 'an earlier instance of this Resource', + '\n media: "all" in latest props, missing or null in original props', + componentStack(['link', 'head', 'html']), + ); + expect(mockError).toHaveBeenCalledWith( + 'Warning: A %s with href "%s" has props that disagree with those found on %s. Resources always use the props' + + ' that were provided the first time they are encountered so any differences will be ignored. Please' + + ' update Resources that share an href to have props that agree. The differences are described below.%s%s', + 'style Resource', + 'baz', + 'an earlier instance of this Resource', + '\n media: "all" in latest props, "some" in original props', + componentStack(['link', 'head', 'html']), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + } finally { + console.error = originalConsoleError; + } + }); + + // @gate enableFloat + it('warns when style Resource props differ or are added for the same href', async () => { + const originalConsoleError = console.error; + const mockError = jest.fn(); + console.error = (...args) => { + mockError(...args.map(normalizeCodeLocInfo)); + }; + try { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + + + + + + + + + + + , + ); + pipe(writable); + }); + expect(getVisibleChildren(document)).toEqual( + + + + + + + + , + ); + + if (__DEV__) { + expect(mockError).toHaveBeenCalledTimes(3); + expect(mockError).toHaveBeenCalledWith( + 'Warning: A %s with href "%s" has props that disagree with those found on %s. Resources always use the props' + + ' that were provided the first time they are encountered so any differences will be ignored. Please' + + ' update Resources that share an href to have props that agree. The differences are described below.%s%s', + 'style Resource', + 'foo', + 'an earlier instance of this Resource', + '\n precedence: "foonew" in latest props, "foo" in original props', + componentStack(['link', 'head', 'html']), + ); + expect(mockError).toHaveBeenCalledWith( + 'Warning: A %s with href "%s" has props that disagree with those found on %s. Resources always use the props' + + ' that were provided the first time they are encountered so any differences will be ignored. Please' + + ' update Resources that share an href to have props that agree. The differences are described below.%s%s', + 'style Resource', + 'bar', + 'an earlier instance of this Resource', + '\n data-foo: "a new value" in latest props, missing or null in original props', + componentStack(['link', 'head', 'html']), + ); + expect(mockError).toHaveBeenCalledWith( + 'Warning: A %s with href "%s" has props that disagree with those found on %s. Resources always use the props' + + ' that were provided the first time they are encountered so any differences will be ignored. Please' + + ' update Resources that share an href to have props that agree. The differences are described below.%s%s', + 'style Resource', + 'baz', + 'an earlier instance of this Resource', + '\n data-foo: "a new value" in latest props, "an original value" in original props', + componentStack(['link', 'head', 'html']), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + } finally { + console.error = originalConsoleError; + } + }); + + // @gate enableFloat + it('warns when style Resource includes any combination of onLoad, onError, or disabled props', async () => { + const originalConsoleError = console.error; + const mockError = jest.fn(); + console.error = (...args) => { + mockError(...args.map(normalizeCodeLocInfo)); + }; + try { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + {}} + onError={() => {}} + /> + {}} + /> + {}} + /> + + + + , + ); + pipe(writable); + }); + // precedence is removed from the stylesheets because it is considered a reserved prop for + // stylesheets to opt into resource semantics. + expect(getVisibleChildren(document)).toEqual( + + + + + + + + + + + + + , + ); + + if (__DEV__) { + expect(mockError).toHaveBeenCalledTimes(4); + expect(mockError).toHaveBeenCalledWith( + 'Warning: A link (rel="stylesheet") element with href "%s" has the precedence prop but also included the %s.' + + ' When using %s React will opt out of Resource behavior. If you meant for this' + + ' element to be treated as a Resource remove the %s. Otherwise remove the precedence prop.%s', + 'foo', + 'onLoad and onError props', + 'onLoad, onError, or disabled', + 'onLoad and onError props', + componentStack(['link', 'head', 'html']), + ); + expect(mockError).toHaveBeenCalledWith( + 'Warning: A link (rel="stylesheet") element with href "%s" has the precedence prop but also included the %s.' + + ' When using %s React will opt out of Resource behavior. If you meant for this' + + ' element to be treated as a Resource remove the %s. Otherwise remove the precedence prop.%s', + 'bar', + 'onLoad prop', + 'onLoad, onError, or disabled', + 'onLoad prop', + componentStack(['link', 'head', 'html']), + ); + expect(mockError).toHaveBeenCalledWith( + 'Warning: A link (rel="stylesheet") element with href "%s" has the precedence prop but also included the %s.' + + ' When using %s React will opt out of Resource behavior. If you meant for this' + + ' element to be treated as a Resource remove the %s. Otherwise remove the precedence prop.%s', + 'baz', + 'onError prop', + 'onLoad, onError, or disabled', + 'onError prop', + componentStack(['link', 'head', 'html']), + ); + expect(mockError).toHaveBeenCalledWith( + 'Warning: A link (rel="stylesheet") element with href "%s" has the precedence prop but also included the %s.' + + ' When using %s React will opt out of Resource behavior. If you meant for this' + + ' element to be treated as a Resource remove the %s. Otherwise remove the precedence prop.%s', + 'qux', + 'disabled prop', + 'onLoad, onError, or disabled', + 'disabled prop', + componentStack(['link', 'head', 'html']), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + } finally { + console.error = originalConsoleError; + } + }); + + // @gate enableFloat + it('warns when preload Resources have new or different values for props', async () => { + const originalConsoleError = console.error; + const mockError = jest.fn(); + console.error = (...args) => { + mockError(...args.map(normalizeCodeLocInfo)); + }; + try { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + + + + + + + , + ); + pipe(writable); + }); + expect(getVisibleChildren(document)).toEqual( + + + + + + + , + ); + + if (__DEV__) { + expect(mockError).toHaveBeenCalledTimes(2); + expect(mockError).toHaveBeenCalledWith( + 'Warning: A %s with href "%s" has props that disagree with those found on %s. Resources always use the props' + + ' that were provided the first time they are encountered so any differences will be ignored. Please' + + ' update Resources that share an href to have props that agree. The differences are described below.%s%s', + 'preload Resource (as "style")', + 'foo', + 'an earlier instance of this Resource', + '\n data-foo: "a new value" in latest props, "a current value" in original props', + componentStack(['link', 'head', 'html']), + ); + expect(mockError).toHaveBeenCalledWith( + 'Warning: A %s is using the same href "%s" as a %s. This is always an error and React will only keep the first preload' + + ' for any given href, discarding subsequent instances. To fix, find where you are using this href in link' + + ' tags or in calls to ReactDOM.preload() or ReactDOM.preinit() and either make the Resource types agree or' + + ' update the hrefs to be distinct for different Resource types.%s', + 'preload Resource (as "font")', + 'bar', + 'preload Resource (as "style")', + componentStack(['link', 'head', 'html']), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + } finally { + console.error = originalConsoleError; + } + }); + + // @gate enableFloat + it('warns when an existing preload Resource has certain specific different props from a style Resource of the same href', async () => { + const originalConsoleError = console.error; + const mockError = jest.fn(); + console.error = (...args) => { + mockError(...args.map(normalizeCodeLocInfo)); + }; + try { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + + + + , + ); + pipe(writable); + }); + expect(getVisibleChildren(document)).toEqual( + + + + + + , + ); + + if (__DEV__) { + expect(mockError).toHaveBeenCalledTimes(1); + expect(mockError).toHaveBeenCalledWith( + 'Warning: A %s with href "%s" has props that disagree with those found on %s. Resources always use the props' + + ' that were provided the first time they are encountered so any differences will be ignored. Please' + + ' update Resources that share an href to have props that agree. The differences are described below.%s%s', + 'style Resource', + 'foo', + 'a preload Resource (as "style") with the same href', + '\n crossOrigin: "style value" in latest props, "preload value" in original props', + componentStack(['link', 'head', 'html']), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + } finally { + console.error = originalConsoleError; + } + }); + }); +}); diff --git a/packages/react-dom/src/test-utils/ReactTestUtils.js b/packages/react-dom/src/test-utils/ReactTestUtils.js index 319a2cd589fb8..22dbd6f4c0ed9 100644 --- a/packages/react-dom/src/test-utils/ReactTestUtils.js +++ b/packages/react-dom/src/test-utils/ReactTestUtils.js @@ -13,6 +13,7 @@ import { ClassComponent, FunctionComponent, HostComponent, + HostResource, HostText, } from 'react-reconciler/src/ReactWorkTags'; import {SyntheticEvent} from 'react-dom-bindings/src/events/SyntheticEvent'; @@ -21,6 +22,7 @@ import { rethrowCaughtError, invokeGuardedCallbackAndCatchFirstError, } from 'shared/ReactErrorUtils'; +import {enableFloat} from 'shared/ReactFeatureFlags'; import assign from 'shared/assign'; import isArray from 'shared/isArray'; @@ -59,7 +61,8 @@ function findAllInRenderedFiberTreeInternal(fiber, test) { node.tag === HostComponent || node.tag === HostText || node.tag === ClassComponent || - node.tag === FunctionComponent + node.tag === FunctionComponent || + (enableFloat ? node.tag === HostResource : false) ) { const publicInst = node.stateNode; if (test(publicInst)) { diff --git a/packages/react-native-renderer/src/ReactFabricHostConfig.js b/packages/react-native-renderer/src/ReactFabricHostConfig.js index 3c66e0ad8cd0b..9b591ccf94baf 100644 --- a/packages/react-native-renderer/src/ReactFabricHostConfig.js +++ b/packages/react-native-renderer/src/ReactFabricHostConfig.js @@ -323,6 +323,7 @@ export * from 'react-reconciler/src/ReactFiberHostConfigWithNoHydration'; export * from 'react-reconciler/src/ReactFiberHostConfigWithNoScopes'; export * from 'react-reconciler/src/ReactFiberHostConfigWithNoTestSelectors'; export * from 'react-reconciler/src/ReactFiberHostConfigWithNoMicrotasks'; +export * from 'react-reconciler/src/ReactFiberHostConfigWithNoResources'; export function appendInitialChild( parentInstance: Instance, diff --git a/packages/react-native-renderer/src/ReactNativeHostConfig.js b/packages/react-native-renderer/src/ReactNativeHostConfig.js index c26c5c389c1e9..8728f4daf5614 100644 --- a/packages/react-native-renderer/src/ReactNativeHostConfig.js +++ b/packages/react-native-renderer/src/ReactNativeHostConfig.js @@ -89,6 +89,7 @@ export * from 'react-reconciler/src/ReactFiberHostConfigWithNoHydration'; export * from 'react-reconciler/src/ReactFiberHostConfigWithNoScopes'; export * from 'react-reconciler/src/ReactFiberHostConfigWithNoTestSelectors'; export * from 'react-reconciler/src/ReactFiberHostConfigWithNoMicrotasks'; +export * from 'react-reconciler/src/ReactFiberHostConfigWithNoResources'; export function appendInitialChild( parentInstance: Instance, diff --git a/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js b/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js index 92685d4f85bef..9dc15791afb79 100644 --- a/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js +++ b/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js @@ -57,6 +57,9 @@ SUSPENSE_UPDATE_TO_COMPLETE[0] = SUSPENSE_UPDATE_TO_COMPLETE_TAG; const SUSPENSE_UPDATE_TO_CLIENT_RENDER = new Uint8Array(1); SUSPENSE_UPDATE_TO_CLIENT_RENDER[0] = SUSPENSE_UPDATE_TO_CLIENT_RENDER_TAG; +export type Resources = void; +export type BoundaryResources = void; + // Per response, export type ResponseState = { nextSuspenseID: number, @@ -142,6 +145,7 @@ export function pushStartInstance( props: Object, responseState: ResponseState, formatContext: FormatContext, + textEmbedded: boolean, ): ReactNodeList { target.push( INSTANCE, @@ -291,6 +295,7 @@ export function writeCompletedBoundaryInstruction( responseState: ResponseState, boundaryID: SuspenseBoundaryID, contentSegmentID: number, + resources: BoundaryResources, ): boolean { writeChunk(destination, SUSPENSE_UPDATE_TO_COMPLETE); writeChunk(destination, formatID(boundaryID)); @@ -309,3 +314,38 @@ export function writeClientRenderBoundaryInstruction( writeChunk(destination, SUSPENSE_UPDATE_TO_CLIENT_RENDER); return writeChunkAndReturn(destination, formatID(boundaryID)); } + +export function writeInitialResources( + destination: Destination, + resources: Resources, + responseState: ResponseState, +): boolean { + return true; +} + +export function writeImmediateResources( + destination: Destination, + resources: Resources, + responseState: ResponseState, +): boolean { + return true; +} + +export function hoistResources( + resources: Resources, + boundaryResources: BoundaryResources, +) {} + +export function hoistResourcesToRoot( + resources: Resources, + boundaryResources: BoundaryResources, +) {} + +export function prepareToRender(resources: Resources) {} +export function cleanupAfterRender() {} +export function createResources() {} +export function createBoundaryResources() {} +export function setCurrentlyRenderingBoundaryResources( + resources: Resources, + boundaryResources: ?BoundaryResources, +) {} diff --git a/packages/react-noop-renderer/src/ReactNoopServer.js b/packages/react-noop-renderer/src/ReactNoopServer.js index 7e0a33b97942b..ef966b3f0d9b5 100644 --- a/packages/react-noop-renderer/src/ReactNoopServer.js +++ b/packages/react-noop-renderer/src/ReactNoopServer.js @@ -51,6 +51,9 @@ type Destination = { stack: Array, }; +type Resources = null; +type BoundaryResources = null; + const POP = Buffer.from('/', 'utf8'); function write(destination: Destination, buffer: Uint8Array): void { @@ -263,6 +266,22 @@ const ReactNoopServer = ReactFizzServer({ ): boolean { boundary.status = 'client-render'; }, + + writeInitialResources() {}, + writeImmediateResources() {}, + + createResources(): Resources { + return null; + }, + + createBoundaryResources(): BoundaryResources { + return null; + }, + + setCurrentlyRenderingBoundaryResources(resources: BoundaryResources) {}, + + prepareToRender() {}, + cleanupAfterRender() {}, }); type Options = { diff --git a/packages/react-noop-renderer/src/createReactNoop.js b/packages/react-noop-renderer/src/createReactNoop.js index 66919fc89239d..7665b5d06dfba 100644 --- a/packages/react-noop-renderer/src/createReactNoop.js +++ b/packages/react-noop-renderer/src/createReactNoop.js @@ -478,6 +478,8 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { const endTime = Scheduler.unstable_now(); callback(endTime); }, + prepareToRender() {}, + cleanupAfterRender() {}, }; const hostConfig = useMutation diff --git a/packages/react-reconciler/src/ReactFiber.new.js b/packages/react-reconciler/src/ReactFiber.new.js index 3cff042b3fdd0..18a3bf721b33c 100644 --- a/packages/react-reconciler/src/ReactFiber.new.js +++ b/packages/react-reconciler/src/ReactFiber.new.js @@ -21,6 +21,7 @@ import type { } from './ReactFiberOffscreenComponent'; import type {TracingMarkerInstance} from './ReactFiberTracingMarkerComponent.new'; +import {supportsResources, isHostResourceType} from './ReactFiberHostConfig'; import { createRootStrictEffectsByDefault, enableCache, @@ -32,6 +33,7 @@ import { allowConcurrentByDefault, enableTransitionTracing, enableDebugTracing, + enableFloat, } from 'shared/ReactFeatureFlags'; import {NoFlags, Placement, StaticMask} from './ReactFiberFlags'; import {ConcurrentRoot} from './ReactRootTags'; @@ -42,6 +44,7 @@ import { HostComponent, HostText, HostPortal, + HostResource, ForwardRef, Fragment, Mode, @@ -494,7 +497,13 @@ export function createFiberFromTypeAndProps( } } } else if (typeof type === 'string') { - fiberTag = HostComponent; + if (enableFloat && supportsResources) { + fiberTag = isHostResourceType(type, pendingProps) + ? HostResource + : HostComponent; + } else { + fiberTag = HostComponent; + } } else { getTag: switch (type) { case REACT_FRAGMENT_TYPE: diff --git a/packages/react-reconciler/src/ReactFiber.old.js b/packages/react-reconciler/src/ReactFiber.old.js index f4717d6374922..94e0050e8b4d6 100644 --- a/packages/react-reconciler/src/ReactFiber.old.js +++ b/packages/react-reconciler/src/ReactFiber.old.js @@ -21,6 +21,7 @@ import type { } from './ReactFiberOffscreenComponent'; import type {TracingMarkerInstance} from './ReactFiberTracingMarkerComponent.old'; +import {supportsResources, isHostResourceType} from './ReactFiberHostConfig'; import { createRootStrictEffectsByDefault, enableCache, @@ -32,6 +33,7 @@ import { allowConcurrentByDefault, enableTransitionTracing, enableDebugTracing, + enableFloat, } from 'shared/ReactFeatureFlags'; import {NoFlags, Placement, StaticMask} from './ReactFiberFlags'; import {ConcurrentRoot} from './ReactRootTags'; @@ -42,6 +44,7 @@ import { HostComponent, HostText, HostPortal, + HostResource, ForwardRef, Fragment, Mode, @@ -494,7 +497,13 @@ export function createFiberFromTypeAndProps( } } } else if (typeof type === 'string') { - fiberTag = HostComponent; + if (enableFloat && supportsResources) { + fiberTag = isHostResourceType(type, pendingProps) + ? HostResource + : HostComponent; + } else { + fiberTag = HostComponent; + } } else { getTag: switch (type) { case REACT_FRAGMENT_TYPE: diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.new.js b/packages/react-reconciler/src/ReactFiberBeginWork.new.js index 92eab56c0d9f6..3ba2334894d88 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.new.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.new.js @@ -40,6 +40,7 @@ import type {TracingMarkerInstance} from './ReactFiberTracingMarkerComponent.new import { enableCPUSuspense, enableUseMutableSource, + enableFloat, } from 'shared/ReactFeatureFlags'; import checkPropTypes from 'shared/checkPropTypes'; @@ -54,6 +55,7 @@ import { ClassComponent, HostRoot, HostComponent, + HostResource, HostText, HostPortal, ForwardRef, @@ -161,7 +163,9 @@ import { getSuspenseInstanceFallbackErrorDetails, registerSuspenseInstanceRetry, supportsHydration, + supportsResources, isPrimaryRenderer, + getResource, } from './ReactFiberHostConfig'; import type {SuspenseInstance} from './ReactFiberHostConfig'; import {shouldError, shouldSuspend} from './ReactFiberReconciler'; @@ -1580,6 +1584,24 @@ function updateHostComponent( return workInProgress.child; } +function updateHostResource(current, workInProgress, renderLanes) { + pushHostContext(workInProgress); + markRef(current, workInProgress); + const currentProps = current === null ? null : current.memoizedProps; + workInProgress.memoizedState = getResource( + workInProgress.type, + workInProgress.pendingProps, + currentProps, + ); + reconcileChildren( + current, + workInProgress, + workInProgress.pendingProps.children, + renderLanes, + ); + return workInProgress.child; +} + function updateHostText(current, workInProgress) { if (current === null) { tryToClaimNextHydratableInstance(workInProgress); @@ -3651,6 +3673,7 @@ function attemptEarlyBailoutIfNoScheduledUpdate( } resetHydrationState(); break; + case HostResource: case HostComponent: pushHostContext(workInProgress); break; @@ -3985,6 +4008,11 @@ function beginWork( } case HostRoot: return updateHostRoot(current, workInProgress, renderLanes); + case HostResource: + if (enableFloat && supportsResources) { + return updateHostResource(current, workInProgress, renderLanes); + } + // eslint-disable-next-line no-fallthrough case HostComponent: return updateHostComponent(current, workInProgress, renderLanes); case HostText: diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.old.js b/packages/react-reconciler/src/ReactFiberBeginWork.old.js index 84cc43148c884..f654dce025175 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.old.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.old.js @@ -40,6 +40,7 @@ import type {TracingMarkerInstance} from './ReactFiberTracingMarkerComponent.old import { enableCPUSuspense, enableUseMutableSource, + enableFloat, } from 'shared/ReactFeatureFlags'; import checkPropTypes from 'shared/checkPropTypes'; @@ -54,6 +55,7 @@ import { ClassComponent, HostRoot, HostComponent, + HostResource, HostText, HostPortal, ForwardRef, @@ -161,7 +163,9 @@ import { getSuspenseInstanceFallbackErrorDetails, registerSuspenseInstanceRetry, supportsHydration, + supportsResources, isPrimaryRenderer, + getResource, } from './ReactFiberHostConfig'; import type {SuspenseInstance} from './ReactFiberHostConfig'; import {shouldError, shouldSuspend} from './ReactFiberReconciler'; @@ -1580,6 +1584,24 @@ function updateHostComponent( return workInProgress.child; } +function updateHostResource(current, workInProgress, renderLanes) { + pushHostContext(workInProgress); + markRef(current, workInProgress); + const currentProps = current === null ? null : current.memoizedProps; + workInProgress.memoizedState = getResource( + workInProgress.type, + workInProgress.pendingProps, + currentProps, + ); + reconcileChildren( + current, + workInProgress, + workInProgress.pendingProps.children, + renderLanes, + ); + return workInProgress.child; +} + function updateHostText(current, workInProgress) { if (current === null) { tryToClaimNextHydratableInstance(workInProgress); @@ -3651,6 +3673,7 @@ function attemptEarlyBailoutIfNoScheduledUpdate( } resetHydrationState(); break; + case HostResource: case HostComponent: pushHostContext(workInProgress); break; @@ -3985,6 +4008,11 @@ function beginWork( } case HostRoot: return updateHostRoot(current, workInProgress, renderLanes); + case HostResource: + if (enableFloat && supportsResources) { + return updateHostResource(current, workInProgress, renderLanes); + } + // eslint-disable-next-line no-fallthrough case HostComponent: return updateHostComponent(current, workInProgress, renderLanes); case HostText: diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.new.js b/packages/react-reconciler/src/ReactFiberCommitWork.new.js index dfac5d034f375..71937836b59d0 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.new.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.new.js @@ -51,6 +51,7 @@ import { enableTransitionTracing, enableUseEventHook, enableStrictEffects, + enableFloat, } from 'shared/ReactFeatureFlags'; import { FunctionComponent, @@ -58,6 +59,7 @@ import { ClassComponent, HostRoot, HostComponent, + HostResource, HostText, HostPortal, Profiler, @@ -73,7 +75,6 @@ import { CacheComponent, TracingMarkerComponent, } from './ReactWorkTags'; -import {detachDeletedInstance} from './ReactFiberHostConfig'; import { NoFlags, ContentReset, @@ -117,6 +118,7 @@ import { supportsMutation, supportsPersistence, supportsHydration, + supportsResources, commitMount, commitUpdate, resetTextContent, @@ -141,6 +143,9 @@ import { prepareScopeUpdate, prepareForCommit, beforeActiveInstanceBlur, + detachDeletedInstance, + acquireResource, + releaseResource, } from './ReactFiberHostConfig'; import { captureCommitPhaseError, @@ -493,6 +498,7 @@ function commitBeforeMutationEffectsOnFiber(finishedWork: Fiber) { break; } case HostComponent: + case HostResource: case HostText: case HostPortal: case IncompleteClassComponent: @@ -1064,6 +1070,34 @@ function commitLayoutEffectOnFiber( } break; } + case HostResource: { + if (enableFloat && supportsResources) { + recursivelyTraverseLayoutEffects( + finishedRoot, + finishedWork, + committedLanes, + ); + + if (flags & Update) { + const newResource = finishedWork.memoizedState; + if (current !== null) { + const currentResource = current.memoizedState; + if (currentResource !== newResource) { + releaseResource(currentResource); + } + } + finishedWork.stateNode = newResource + ? acquireResource(newResource) + : null; + } + + if (flags & Ref) { + safelyAttachRef(finishedWork, finishedWork.return); + } + break; + } + } + // eslint-disable-next-line-no-fallthrough case HostComponent: { recursivelyTraverseLayoutEffects( finishedRoot, @@ -1450,7 +1484,10 @@ function hideOrUnhideAllChildren(finishedWork, isHidden) { // children to find all the terminal nodes. let node: Fiber = finishedWork; while (true) { - if (node.tag === HostComponent) { + if ( + node.tag === HostComponent || + (enableFloat && supportsResources ? node.tag === HostResource : false) + ) { if (hostSubtreeRoot === null) { hostSubtreeRoot = node; try { @@ -1522,6 +1559,7 @@ function commitAttachRef(finishedWork: Fiber) { const instance = finishedWork.stateNode; let instanceToUse; switch (finishedWork.tag) { + case HostResource: case HostComponent: instanceToUse = getPublicInstance(instance); break; @@ -1726,7 +1764,8 @@ function isHostParent(fiber: Fiber): boolean { return ( fiber.tag === HostComponent || fiber.tag === HostRoot || - fiber.tag === HostPortal + fiber.tag === HostPortal || + (enableFloat && supportsResources ? fiber.tag === HostResource : false) ); } @@ -1973,6 +2012,23 @@ function commitDeletionEffectsOnFiber( // into their subtree. There are simpler cases in the inner switch // that don't modify the stack. switch (deletedFiber.tag) { + case HostResource: { + if (enableFloat && supportsResources) { + if (!offscreenSubtreeWasHidden) { + safelyDetachRef(deletedFiber, nearestMountedAncestor); + } + + recursivelyTraverseDeletionEffects( + finishedRoot, + nearestMountedAncestor, + deletedFiber, + ); + + releaseResource(deletedFiber.memoizedState); + return; + } + } + // eslint-disable-next-line no-fallthrough case HostComponent: { if (!offscreenSubtreeWasHidden) { safelyDetachRef(deletedFiber, nearestMountedAncestor); @@ -2469,6 +2525,20 @@ function commitMutationEffectsOnFiber( } return; } + case HostResource: { + if (enableFloat && supportsResources) { + recursivelyTraverseMutationEffects(root, finishedWork, lanes); + commitReconciliationEffects(finishedWork); + + if (flags & Ref) { + if (current !== null) { + safelyDetachRef(current, current.return); + } + } + return; + } + } + // eslint-disable-next-line-no-fallthrough case HostComponent: { recursivelyTraverseMutationEffects(root, finishedWork, lanes); commitReconciliationEffects(finishedWork); @@ -2860,6 +2930,7 @@ export function disappearLayoutEffects(finishedWork: Fiber) { recursivelyTraverseDisappearLayoutEffects(finishedWork); break; } + case HostResource: case HostComponent: { // TODO (Offscreen) Check: flags & RefStatic safelyDetachRef(finishedWork, finishedWork.return); @@ -2961,6 +3032,7 @@ export function reappearLayoutEffects( // case HostRoot: { // ... // } + case HostResource: case HostComponent: { recursivelyTraverseReappearLayoutEffects( finishedRoot, diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.old.js b/packages/react-reconciler/src/ReactFiberCommitWork.old.js index f75a7edda07bd..f54bfe22d9652 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.old.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.old.js @@ -51,6 +51,7 @@ import { enableTransitionTracing, enableUseEventHook, enableStrictEffects, + enableFloat, } from 'shared/ReactFeatureFlags'; import { FunctionComponent, @@ -58,6 +59,7 @@ import { ClassComponent, HostRoot, HostComponent, + HostResource, HostText, HostPortal, Profiler, @@ -73,7 +75,6 @@ import { CacheComponent, TracingMarkerComponent, } from './ReactWorkTags'; -import {detachDeletedInstance} from './ReactFiberHostConfig'; import { NoFlags, ContentReset, @@ -117,6 +118,7 @@ import { supportsMutation, supportsPersistence, supportsHydration, + supportsResources, commitMount, commitUpdate, resetTextContent, @@ -141,6 +143,9 @@ import { prepareScopeUpdate, prepareForCommit, beforeActiveInstanceBlur, + detachDeletedInstance, + acquireResource, + releaseResource, } from './ReactFiberHostConfig'; import { captureCommitPhaseError, @@ -493,6 +498,7 @@ function commitBeforeMutationEffectsOnFiber(finishedWork: Fiber) { break; } case HostComponent: + case HostResource: case HostText: case HostPortal: case IncompleteClassComponent: @@ -1064,6 +1070,34 @@ function commitLayoutEffectOnFiber( } break; } + case HostResource: { + if (enableFloat && supportsResources) { + recursivelyTraverseLayoutEffects( + finishedRoot, + finishedWork, + committedLanes, + ); + + if (flags & Update) { + const newResource = finishedWork.memoizedState; + if (current !== null) { + const currentResource = current.memoizedState; + if (currentResource !== newResource) { + releaseResource(currentResource); + } + } + finishedWork.stateNode = newResource + ? acquireResource(newResource) + : null; + } + + if (flags & Ref) { + safelyAttachRef(finishedWork, finishedWork.return); + } + break; + } + } + // eslint-disable-next-line-no-fallthrough case HostComponent: { recursivelyTraverseLayoutEffects( finishedRoot, @@ -1450,7 +1484,10 @@ function hideOrUnhideAllChildren(finishedWork, isHidden) { // children to find all the terminal nodes. let node: Fiber = finishedWork; while (true) { - if (node.tag === HostComponent) { + if ( + node.tag === HostComponent || + (enableFloat && supportsResources ? node.tag === HostResource : false) + ) { if (hostSubtreeRoot === null) { hostSubtreeRoot = node; try { @@ -1522,6 +1559,7 @@ function commitAttachRef(finishedWork: Fiber) { const instance = finishedWork.stateNode; let instanceToUse; switch (finishedWork.tag) { + case HostResource: case HostComponent: instanceToUse = getPublicInstance(instance); break; @@ -1726,7 +1764,8 @@ function isHostParent(fiber: Fiber): boolean { return ( fiber.tag === HostComponent || fiber.tag === HostRoot || - fiber.tag === HostPortal + fiber.tag === HostPortal || + (enableFloat && supportsResources ? fiber.tag === HostResource : false) ); } @@ -1973,6 +2012,23 @@ function commitDeletionEffectsOnFiber( // into their subtree. There are simpler cases in the inner switch // that don't modify the stack. switch (deletedFiber.tag) { + case HostResource: { + if (enableFloat && supportsResources) { + if (!offscreenSubtreeWasHidden) { + safelyDetachRef(deletedFiber, nearestMountedAncestor); + } + + recursivelyTraverseDeletionEffects( + finishedRoot, + nearestMountedAncestor, + deletedFiber, + ); + + releaseResource(deletedFiber.memoizedState); + return; + } + } + // eslint-disable-next-line no-fallthrough case HostComponent: { if (!offscreenSubtreeWasHidden) { safelyDetachRef(deletedFiber, nearestMountedAncestor); @@ -2469,6 +2525,20 @@ function commitMutationEffectsOnFiber( } return; } + case HostResource: { + if (enableFloat && supportsResources) { + recursivelyTraverseMutationEffects(root, finishedWork, lanes); + commitReconciliationEffects(finishedWork); + + if (flags & Ref) { + if (current !== null) { + safelyDetachRef(current, current.return); + } + } + return; + } + } + // eslint-disable-next-line-no-fallthrough case HostComponent: { recursivelyTraverseMutationEffects(root, finishedWork, lanes); commitReconciliationEffects(finishedWork); @@ -2860,6 +2930,7 @@ export function disappearLayoutEffects(finishedWork: Fiber) { recursivelyTraverseDisappearLayoutEffects(finishedWork); break; } + case HostResource: case HostComponent: { // TODO (Offscreen) Check: flags & RefStatic safelyDetachRef(finishedWork, finishedWork.return); @@ -2961,6 +3032,7 @@ export function reappearLayoutEffects( // case HostRoot: { // ... // } + case HostResource: case HostComponent: { recursivelyTraverseReappearLayoutEffects( finishedRoot, diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.new.js b/packages/react-reconciler/src/ReactFiberCompleteWork.new.js index 592bef4fbd25d..eec23dfe83538 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.new.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.new.js @@ -45,6 +45,7 @@ import { ClassComponent, HostRoot, HostComponent, + HostResource, HostText, HostPortal, ContextProvider, @@ -92,6 +93,7 @@ import { prepareUpdate, supportsMutation, supportsPersistence, + supportsResources, cloneInstance, cloneHiddenInstance, cloneHiddenTextInstance, @@ -144,6 +146,7 @@ import { enableProfilerTimer, enableCache, enableTransitionTracing, + enableFloat, } from 'shared/ReactFeatureFlags'; import { renderDidSuspend, @@ -954,6 +957,26 @@ function completeWork( } return null; } + case HostResource: { + if (enableFloat && supportsResources) { + popHostContext(workInProgress); + const currentRef = current ? current.ref : null; + if (currentRef !== workInProgress.ref) { + markRef(workInProgress); + } + if ( + current === null || + current.memoizedState !== workInProgress.memoizedState + ) { + // The workInProgress resource is different than the current one or the current + // one does not exist + markUpdate(workInProgress); + } + bubbleProperties(workInProgress); + return null; + } + } + // eslint-disable-next-line-no-fallthrough case HostComponent: { popHostContext(workInProgress); const type = workInProgress.type; diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.old.js b/packages/react-reconciler/src/ReactFiberCompleteWork.old.js index f3ba7bf0799a0..10be55575c530 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.old.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.old.js @@ -45,6 +45,7 @@ import { ClassComponent, HostRoot, HostComponent, + HostResource, HostText, HostPortal, ContextProvider, @@ -92,6 +93,7 @@ import { prepareUpdate, supportsMutation, supportsPersistence, + supportsResources, cloneInstance, cloneHiddenInstance, cloneHiddenTextInstance, @@ -144,6 +146,7 @@ import { enableProfilerTimer, enableCache, enableTransitionTracing, + enableFloat, } from 'shared/ReactFeatureFlags'; import { renderDidSuspend, @@ -954,6 +957,26 @@ function completeWork( } return null; } + case HostResource: { + if (enableFloat && supportsResources) { + popHostContext(workInProgress); + const currentRef = current ? current.ref : null; + if (currentRef !== workInProgress.ref) { + markRef(workInProgress); + } + if ( + current === null || + current.memoizedState !== workInProgress.memoizedState + ) { + // The workInProgress resource is different than the current one or the current + // one does not exist + markUpdate(workInProgress); + } + bubbleProperties(workInProgress); + return null; + } + } + // eslint-disable-next-line-no-fallthrough case HostComponent: { popHostContext(workInProgress); const type = workInProgress.type; diff --git a/packages/react-reconciler/src/ReactFiberComponentStack.js b/packages/react-reconciler/src/ReactFiberComponentStack.js index 43ad9fd3b542b..3821758c67a47 100644 --- a/packages/react-reconciler/src/ReactFiberComponentStack.js +++ b/packages/react-reconciler/src/ReactFiberComponentStack.js @@ -11,6 +11,7 @@ import type {Fiber} from './ReactInternalTypes'; import { HostComponent, + HostResource, LazyComponent, SuspenseComponent, SuspenseListComponent, @@ -34,6 +35,7 @@ function describeFiber(fiber: Fiber): string { : null; const source = __DEV__ ? fiber._debugSource : null; switch (fiber.tag) { + case HostResource: case HostComponent: return describeBuiltInComponentFrame(fiber.type, source, owner); case LazyComponent: diff --git a/packages/react-reconciler/src/ReactFiberHostConfigWithNoHydration.js b/packages/react-reconciler/src/ReactFiberHostConfigWithNoHydration.js index 9eba0aad9dc21..f61c0eb7789e7 100644 --- a/packages/react-reconciler/src/ReactFiberHostConfigWithNoHydration.js +++ b/packages/react-reconciler/src/ReactFiberHostConfigWithNoHydration.js @@ -24,12 +24,10 @@ export const supportsHydration = false; export const canHydrateInstance = shim; export const canHydrateTextInstance = shim; export const canHydrateSuspenseInstance = shim; -export const isHydratableResource = shim; export const isSuspenseInstancePending = shim; export const isSuspenseInstanceFallback = shim; export const getSuspenseInstanceFallbackErrorDetails = shim; export const registerSuspenseInstanceRetry = shim; -export const getMatchingResourceInstance = shim; export const getNextHydratableSibling = shim; export const getFirstHydratableChild = shim; export const getFirstHydratableChildWithinContainer = shim; diff --git a/packages/react-reconciler/src/ReactFiberHostConfigWithNoResources.js b/packages/react-reconciler/src/ReactFiberHostConfigWithNoResources.js new file mode 100644 index 0000000000000..a63d1d24218df --- /dev/null +++ b/packages/react-reconciler/src/ReactFiberHostConfigWithNoResources.js @@ -0,0 +1,29 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +// Renderers that don't support hydration +// can re-export everything from this module. + +function shim(...args: any) { + throw new Error( + 'The current renderer does not support Resources. ' + + 'This error is likely caused by a bug in React. ' + + 'Please file an issue.', + ); +} + +// Resources (when unsupported) +export const supportsResources = false; +export const isHostResourceInstance = shim; +export const isHostResourceType = shim; +export const getResource = shim; +export const acquireResource = shim; +export const releaseResource = shim; +export const prepareToRender = shim; +export const cleanupAfterRender = shim; diff --git a/packages/react-reconciler/src/ReactFiberHotReloading.new.js b/packages/react-reconciler/src/ReactFiberHotReloading.new.js index 4f289fe4195e4..96eefa62f7905 100644 --- a/packages/react-reconciler/src/ReactFiberHotReloading.new.js +++ b/packages/react-reconciler/src/ReactFiberHotReloading.new.js @@ -37,6 +37,7 @@ import { FunctionComponent, ForwardRef, HostComponent, + HostResource, HostPortal, HostRoot, MemoComponent, @@ -47,6 +48,7 @@ import { REACT_MEMO_TYPE, REACT_LAZY_TYPE, } from 'shared/ReactSymbols'; +import {enableFloat} from 'shared/ReactFeatureFlags'; let resolveFamily: RefreshHandler | null = null; // $FlowFixMe Flow gets confused by a WeakSet feature check below. @@ -449,7 +451,10 @@ function findChildHostInstancesForFiberShallowly( let node: Fiber = fiber; let foundHostInstances = false; while (true) { - if (node.tag === HostComponent) { + if ( + node.tag === HostComponent || + (enableFloat ? node.tag === HostResource : false) + ) { // We got a match. foundHostInstances = true; hostInstances.add(node.stateNode); diff --git a/packages/react-reconciler/src/ReactFiberHotReloading.old.js b/packages/react-reconciler/src/ReactFiberHotReloading.old.js index 55f0f94cf8a44..5b08bdf35f311 100644 --- a/packages/react-reconciler/src/ReactFiberHotReloading.old.js +++ b/packages/react-reconciler/src/ReactFiberHotReloading.old.js @@ -37,6 +37,7 @@ import { FunctionComponent, ForwardRef, HostComponent, + HostResource, HostPortal, HostRoot, MemoComponent, @@ -47,6 +48,7 @@ import { REACT_MEMO_TYPE, REACT_LAZY_TYPE, } from 'shared/ReactSymbols'; +import {enableFloat} from 'shared/ReactFeatureFlags'; let resolveFamily: RefreshHandler | null = null; // $FlowFixMe Flow gets confused by a WeakSet feature check below. @@ -449,7 +451,10 @@ function findChildHostInstancesForFiberShallowly( let node: Fiber = fiber; let foundHostInstances = false; while (true) { - if (node.tag === HostComponent) { + if ( + node.tag === HostComponent || + (enableFloat ? node.tag === HostResource : false) + ) { // We got a match. foundHostInstances = true; hostInstances.add(node.stateNode); diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.new.js b/packages/react-reconciler/src/ReactFiberHydrationContext.new.js index 66c28274ae0cf..82df8620e0fcb 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.new.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.new.js @@ -34,7 +34,6 @@ import { NoFlags, DidCapture, } from './ReactFiberFlags'; -import {enableFloat} from 'shared/ReactFeatureFlags'; import { createFiberFromHostInstanceForDeletion, @@ -46,9 +45,7 @@ import { canHydrateInstance, canHydrateTextInstance, canHydrateSuspenseInstance, - isHydratableResource, getNextHydratableSibling, - getMatchingResourceInstance, getFirstHydratableChild, getFirstHydratableChildWithinContainer, getFirstHydratableChildWithinSuspenseInstance, @@ -78,7 +75,6 @@ import { restoreSuspendedTreeContext, } from './ReactFiberTreeContext.new'; import {queueRecoverableErrors} from './ReactFiberWorkLoop.new'; -import {getRootHostContainer} from './ReactFiberHostContext.new'; // The deepest Fiber on the stack involved in a hydration context. // This may have been an insertion or a hydration. @@ -408,19 +404,6 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void { if (!isHydrating) { return; } - if (enableFloat) { - if ( - fiber.tag === HostComponent && - isHydratableResource(fiber.type, fiber.pendingProps) - ) { - fiber.stateNode = getMatchingResourceInstance( - fiber.type, - fiber.pendingProps, - getRootHostContainer(), - ); - return; - } - } let nextInstance = nextHydratableInstance; if (!nextInstance) { if (shouldClientRenderOnMismatch(fiber)) { @@ -613,30 +596,6 @@ function popHydrationState(fiber: Fiber): boolean { if (!supportsHydration) { return false; } - if ( - enableFloat && - isHydrating && - isHydratableResource(fiber.type, fiber.memoizedProps) - ) { - if (fiber.stateNode === null) { - if (__DEV__) { - const rel = fiber.memoizedProps.rel - ? `rel="${fiber.memoizedProps.rel}" ` - : ''; - const href = fiber.memoizedProps.href - ? `href="${fiber.memoizedProps.href}"` - : ''; - console.error( - 'A matching Hydratable Resource was not found in the DOM for <%s %s%s>.', - fiber.type, - rel, - href, - ); - } - throwOnHydrationMismatch(fiber); - } - return true; - } if (fiber !== hydrationParentFiber) { // We're deeper than the current hydration context, inside an inserted // tree. diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.old.js b/packages/react-reconciler/src/ReactFiberHydrationContext.old.js index 10e59b1d2bf0a..3f6ade7832a59 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.old.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.old.js @@ -34,7 +34,6 @@ import { NoFlags, DidCapture, } from './ReactFiberFlags'; -import {enableFloat} from 'shared/ReactFeatureFlags'; import { createFiberFromHostInstanceForDeletion, @@ -46,9 +45,7 @@ import { canHydrateInstance, canHydrateTextInstance, canHydrateSuspenseInstance, - isHydratableResource, getNextHydratableSibling, - getMatchingResourceInstance, getFirstHydratableChild, getFirstHydratableChildWithinContainer, getFirstHydratableChildWithinSuspenseInstance, @@ -78,7 +75,6 @@ import { restoreSuspendedTreeContext, } from './ReactFiberTreeContext.old'; import {queueRecoverableErrors} from './ReactFiberWorkLoop.old'; -import {getRootHostContainer} from './ReactFiberHostContext.old'; // The deepest Fiber on the stack involved in a hydration context. // This may have been an insertion or a hydration. @@ -408,19 +404,6 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void { if (!isHydrating) { return; } - if (enableFloat) { - if ( - fiber.tag === HostComponent && - isHydratableResource(fiber.type, fiber.pendingProps) - ) { - fiber.stateNode = getMatchingResourceInstance( - fiber.type, - fiber.pendingProps, - getRootHostContainer(), - ); - return; - } - } let nextInstance = nextHydratableInstance; if (!nextInstance) { if (shouldClientRenderOnMismatch(fiber)) { @@ -613,30 +596,6 @@ function popHydrationState(fiber: Fiber): boolean { if (!supportsHydration) { return false; } - if ( - enableFloat && - isHydrating && - isHydratableResource(fiber.type, fiber.memoizedProps) - ) { - if (fiber.stateNode === null) { - if (__DEV__) { - const rel = fiber.memoizedProps.rel - ? `rel="${fiber.memoizedProps.rel}" ` - : ''; - const href = fiber.memoizedProps.href - ? `href="${fiber.memoizedProps.href}"` - : ''; - console.error( - 'A matching Hydratable Resource was not found in the DOM for <%s %s%s>.', - fiber.type, - rel, - href, - ); - } - throwOnHydrationMismatch(fiber); - } - return true; - } if (fiber !== hydrationParentFiber) { // We're deeper than the current hydration context, inside an inserted // tree. diff --git a/packages/react-reconciler/src/ReactFiberTreeReflection.js b/packages/react-reconciler/src/ReactFiberTreeReflection.js index 4aa5a203ae3a9..67600492d13ff 100644 --- a/packages/react-reconciler/src/ReactFiberTreeReflection.js +++ b/packages/react-reconciler/src/ReactFiberTreeReflection.js @@ -17,12 +17,14 @@ import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFrom import { ClassComponent, HostComponent, + HostResource, HostRoot, HostPortal, HostText, SuspenseComponent, } from './ReactWorkTags'; import {NoFlags, Placement, Hydrating} from './ReactFiberFlags'; +import {enableFloat} from 'shared/ReactFeatureFlags'; const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner; @@ -273,7 +275,11 @@ export function findCurrentHostFiber(parent: Fiber): Fiber | null { function findCurrentHostFiberImpl(node: Fiber) { // Next we'll drill down this component to find the first HostComponent/Text. - if (node.tag === HostComponent || node.tag === HostText) { + if ( + node.tag === HostComponent || + node.tag === HostText || + (enableFloat ? node.tag === HostResource : false) + ) { return node; } @@ -298,7 +304,11 @@ export function findCurrentHostFiberWithNoPortals(parent: Fiber): Fiber | null { function findCurrentHostFiberWithNoPortalsImpl(node: Fiber) { // Next we'll drill down this component to find the first HostComponent/Text. - if (node.tag === HostComponent || node.tag === HostText) { + if ( + node.tag === HostComponent || + node.tag === HostText || + (enableFloat ? node.tag === HostResource : false) + ) { return node; } diff --git a/packages/react-reconciler/src/ReactFiberUnwindWork.new.js b/packages/react-reconciler/src/ReactFiberUnwindWork.new.js index 1de655dfb0264..a5c408681dd04 100644 --- a/packages/react-reconciler/src/ReactFiberUnwindWork.new.js +++ b/packages/react-reconciler/src/ReactFiberUnwindWork.new.js @@ -19,6 +19,7 @@ import { ClassComponent, HostRoot, HostComponent, + HostResource, HostPortal, ContextProvider, SuspenseComponent, @@ -115,6 +116,7 @@ function unwindWork( // We unwound to the root without completing it. Exit. return null; } + case HostResource: case HostComponent: { // TODO: popHydrationState popHostContext(workInProgress); @@ -233,6 +235,7 @@ function unwindInterruptedWork( resetMutableSourceWorkInProgressVersions(); break; } + case HostResource: case HostComponent: { popHostContext(interruptedWork); break; diff --git a/packages/react-reconciler/src/ReactFiberUnwindWork.old.js b/packages/react-reconciler/src/ReactFiberUnwindWork.old.js index 218d41919eb2a..870983968cc6f 100644 --- a/packages/react-reconciler/src/ReactFiberUnwindWork.old.js +++ b/packages/react-reconciler/src/ReactFiberUnwindWork.old.js @@ -19,6 +19,7 @@ import { ClassComponent, HostRoot, HostComponent, + HostResource, HostPortal, ContextProvider, SuspenseComponent, @@ -115,6 +116,7 @@ function unwindWork( // We unwound to the root without completing it. Exit. return null; } + case HostResource: case HostComponent: { // TODO: popHydrationState popHostContext(workInProgress); @@ -233,6 +235,7 @@ function unwindInterruptedWork( resetMutableSourceWorkInProgressVersions(); break; } + case HostResource: case HostComponent: { popHostContext(interruptedWork); break; diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js index 8f9e856772d5e..5db8bb57e9b89 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js @@ -84,6 +84,8 @@ import { supportsMicrotasks, errorHydratingContainer, scheduleMicrotask, + prepareRendererToRender, + resetRendererAfterRender, } from './ReactFiberHostConfig'; import { @@ -1851,6 +1853,7 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) { const prevExecutionContext = executionContext; executionContext |= RenderContext; const prevDispatcher = pushDispatcher(); + prepareRendererToRender(root.containerInfo); // If the root or lanes have changed, throw out the existing stack // and prepare a fresh one. Otherwise we'll continue where we left off. @@ -1897,6 +1900,7 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) { executionContext = prevExecutionContext; popDispatcher(prevDispatcher); + resetRendererAfterRender(); if (workInProgress !== null) { // This is a sync render, so we should have finished the whole tree. @@ -1951,6 +1955,7 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) { const prevExecutionContext = executionContext; executionContext |= RenderContext; const prevDispatcher = pushDispatcher(); + prepareRendererToRender(root.containerInfo); // If the root or lanes have changed, throw out the existing stack // and prepare a fresh one. Otherwise we'll continue where we left off. @@ -2003,6 +2008,7 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) { resetContextDependencies(); popDispatcher(prevDispatcher); + resetRendererAfterRender(); executionContext = prevExecutionContext; if (__DEV__) { diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js index ef4f355909c36..3545a49ca8854 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js @@ -84,6 +84,8 @@ import { supportsMicrotasks, errorHydratingContainer, scheduleMicrotask, + prepareRendererToRender, + resetRendererAfterRender, } from './ReactFiberHostConfig'; import { @@ -1851,6 +1853,7 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) { const prevExecutionContext = executionContext; executionContext |= RenderContext; const prevDispatcher = pushDispatcher(); + prepareRendererToRender(root.containerInfo); // If the root or lanes have changed, throw out the existing stack // and prepare a fresh one. Otherwise we'll continue where we left off. @@ -1897,6 +1900,7 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) { executionContext = prevExecutionContext; popDispatcher(prevDispatcher); + resetRendererAfterRender(); if (workInProgress !== null) { // This is a sync render, so we should have finished the whole tree. @@ -1951,6 +1955,7 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) { const prevExecutionContext = executionContext; executionContext |= RenderContext; const prevDispatcher = pushDispatcher(); + prepareRendererToRender(root.containerInfo); // If the root or lanes have changed, throw out the existing stack // and prepare a fresh one. Otherwise we'll continue where we left off. @@ -2003,6 +2008,7 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) { resetContextDependencies(); popDispatcher(prevDispatcher); + resetRendererAfterRender(); executionContext = prevExecutionContext; if (__DEV__) { diff --git a/packages/react-reconciler/src/ReactTestSelectors.js b/packages/react-reconciler/src/ReactTestSelectors.js index 48640fd6c70b6..3ce6720c85a77 100644 --- a/packages/react-reconciler/src/ReactTestSelectors.js +++ b/packages/react-reconciler/src/ReactTestSelectors.js @@ -10,7 +10,11 @@ import type {Fiber} from 'react-reconciler/src/ReactInternalTypes'; import type {Instance} from './ReactFiberHostConfig'; -import {HostComponent, HostText} from 'react-reconciler/src/ReactWorkTags'; +import { + HostComponent, + HostResource, + HostText, +} from 'react-reconciler/src/ReactWorkTags'; import getComponentNameFromType from 'shared/getComponentNameFromType'; import { findFiberRoot, @@ -150,7 +154,7 @@ function matchSelector(fiber: Fiber, selector: Selector): boolean { ((selector: any): HasPseudoClassSelector).value, ); case ROLE_TYPE: - if (fiber.tag === HostComponent) { + if (fiber.tag === HostComponent || fiber.tag === HostResource) { const node = fiber.stateNode; if ( matchAccessibilityRole(node, ((selector: any): RoleSelector).value) @@ -160,7 +164,11 @@ function matchSelector(fiber: Fiber, selector: Selector): boolean { } break; case TEXT_TYPE: - if (fiber.tag === HostComponent || fiber.tag === HostText) { + if ( + fiber.tag === HostComponent || + fiber.tag === HostText || + fiber.tag === HostResource + ) { const textContent = getTextContent(fiber); if ( textContent !== null && @@ -171,7 +179,7 @@ function matchSelector(fiber: Fiber, selector: Selector): boolean { } break; case TEST_NAME_TYPE: - if (fiber.tag === HostComponent) { + if (fiber.tag === HostComponent || fiber.tag === HostResource) { const dataTestID = fiber.memoizedProps['data-testname']; if ( typeof dataTestID === 'string' && @@ -217,7 +225,10 @@ function findPaths(root: Fiber, selectors: Array): Array { let selectorIndex = ((stack[index++]: any): number); let selector = selectors[selectorIndex]; - if (fiber.tag === HostComponent && isHiddenSubtree(fiber)) { + if ( + (fiber.tag === HostComponent || fiber.tag === HostResource) && + isHiddenSubtree(fiber) + ) { continue; } else { while (selector != null && matchSelector(fiber, selector)) { @@ -249,7 +260,10 @@ function hasMatchingPaths(root: Fiber, selectors: Array): boolean { let selectorIndex = ((stack[index++]: any): number); let selector = selectors[selectorIndex]; - if (fiber.tag === HostComponent && isHiddenSubtree(fiber)) { + if ( + (fiber.tag === HostComponent || fiber.tag === HostResource) && + isHiddenSubtree(fiber) + ) { continue; } else { while (selector != null && matchSelector(fiber, selector)) { @@ -289,7 +303,7 @@ export function findAllNodes( let index = 0; while (index < stack.length) { const node = ((stack[index++]: any): Fiber); - if (node.tag === HostComponent) { + if (node.tag === HostComponent || node.tag === HostResource) { if (isHiddenSubtree(node)) { continue; } @@ -327,7 +341,10 @@ export function getFindAllNodesFailureDescription( let selectorIndex = ((stack[index++]: any): number); const selector = selectors[selectorIndex]; - if (fiber.tag === HostComponent && isHiddenSubtree(fiber)) { + if ( + (fiber.tag === HostComponent || fiber.tag === HostResource) && + isHiddenSubtree(fiber) + ) { continue; } else if (matchSelector(fiber, selector)) { matchedNames.push(selectorToString(selector)); @@ -479,7 +496,7 @@ export function focusWithin( if (isHiddenSubtree(fiber)) { continue; } - if (fiber.tag === HostComponent) { + if (fiber.tag === HostComponent || fiber.tag === HostResource) { const node = fiber.stateNode; if (setFocusIfFocusable(node)) { return true; diff --git a/packages/react-reconciler/src/ReactWorkTags.js b/packages/react-reconciler/src/ReactWorkTags.js index 00d2d93794e9a..0a62312ce87a0 100644 --- a/packages/react-reconciler/src/ReactWorkTags.js +++ b/packages/react-reconciler/src/ReactWorkTags.js @@ -33,7 +33,8 @@ export type WorkTag = | 22 | 23 | 24 - | 25; + | 25 + | 26; export const FunctionComponent = 0; export const ClassComponent = 1; @@ -60,3 +61,4 @@ export const OffscreenComponent = 22; export const LegacyHiddenComponent = 23; export const CacheComponent = 24; export const TracingMarkerComponent = 25; +export const HostResource = 26; diff --git a/packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js b/packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js index 19ddc6c8262f4..f41296621ba9a 100644 --- a/packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js @@ -66,6 +66,8 @@ describe('ReactFiberHostContext', () => { getCurrentEventPriority: function() { return DefaultEventPriority; }, + prepareToRender: function() {}, + cleanupAfterRender: function() {}, supportsMutation: true, requestPostPaintCallback: function() {}, }); @@ -131,6 +133,8 @@ describe('ReactFiberHostContext', () => { return DefaultEventPriority; }, requestPostPaintCallback: function() {}, + prepareToRender: function() {}, + cleanupAfterRender: function() {}, supportsMutation: true, }); diff --git a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js index 517b45ead8fc1..261d96400cd9a 100644 --- a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js @@ -187,6 +187,16 @@ export const didNotFindHydratableTextInstance = export const didNotFindHydratableSuspenseInstance = $$$hostConfig.didNotFindHydratableSuspenseInstance; export const errorHydratingContainer = $$$hostConfig.errorHydratingContainer; -export const isHydratableResource = $$$hostConfig.isHydratableResource; -export const getMatchingResourceInstance = - $$$hostConfig.getMatchingResourceInstance; + +// ------------------- +// Resources +// (optional) +// ------------------- +export const supportsResources = $$$hostConfig.supportsResources; +export const isHostResourceInstance = $$$hostConfig.isHostResourceInstance; +export const isHostResourceType = $$$hostConfig.isHostResourceType; +export const getResource = $$$hostConfig.getResource; +export const acquireResource = $$$hostConfig.acquireResource; +export const releaseResource = $$$hostConfig.releaseResource; +export const prepareToRender = $$$hostConfig.prepareToRender; +export const cleanupAfterRender = $$$hostConfig.cleanupAfterRender; diff --git a/packages/react-reconciler/src/getComponentNameFromFiber.js b/packages/react-reconciler/src/getComponentNameFromFiber.js index 883ac838b975d..4b14f01014714 100644 --- a/packages/react-reconciler/src/getComponentNameFromFiber.js +++ b/packages/react-reconciler/src/getComponentNameFromFiber.js @@ -19,6 +19,7 @@ import { HostRoot, HostPortal, HostComponent, + HostResource, HostText, Fragment, Mode, @@ -77,6 +78,7 @@ export default function getComponentNameFromFiber(fiber: Fiber): string | null { return getWrappedName(type, type.render, 'ForwardRef'); case Fragment: return 'Fragment'; + case HostResource: case HostComponent: // Host component type is the display name (e.g. "div", "View") return type; diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 5f44458e7889c..f1ca5b4203f40 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -24,6 +24,8 @@ import type { SuspenseBoundaryID, ResponseState, FormatContext, + Resources, + BoundaryResources, } from './ReactServerFormatConfig'; import type {ContextSnapshot} from './ReactFizzNewContext'; import type {ComponentStackNode} from './ReactFizzComponentStack'; @@ -63,6 +65,15 @@ import { UNINITIALIZED_SUSPENSE_BOUNDARY_ID, assignSuspenseBoundaryID, getChildFormatContext, + writeInitialResources, + writeImmediateResources, + hoistResources, + hoistResourcesToRoot, + prepareToRender, + cleanupAfterRender, + setCurrentlyRenderingBoundaryResources, + createResources, + createBoundaryResources, } from './ReactServerFormatConfig'; import { constructClassInstance, @@ -147,6 +158,7 @@ type SuspenseBoundary = { completedSegments: Array, // completed but not yet flushed segments. byteSize: number, // used to determine whether to inline children boundaries. fallbackAbortableTasks: Set, // used to cancel task on the fallback if the boundary completes or gets canceled. + resources: BoundaryResources, }; export type Task = { @@ -199,9 +211,11 @@ export opaque type Request = { nextSegmentId: number, allPendingTasks: number, // when it reaches zero, we can close the connection. pendingRootTasks: number, // when this reaches zero, we've finished at least the root boundary. + resources: Resources, completedRootSegment: null | Segment, // Completed but not yet flushed root segments. + rootDidFlush: boolean, abortableTasks: Set, - pingedTasks: Array, + pingedTasks: Array, // High priority tasks that should be worked on first. // Queues to flush in order of priority clientRenderedBoundaries: Array, // Errored or client rendered but not yet flushed. completedBoundaries: Array, // Completed but not yet fully flushed boundaries to show. @@ -262,6 +276,7 @@ export function createRequest( ): Request { const pingedTasks = []; const abortSet: Set = new Set(); + const resources: Resources = createResources(); const request = { destination: null, responseState, @@ -274,7 +289,9 @@ export function createRequest( nextSegmentId: 0, allPendingTasks: 0, pendingRootTasks: 0, + resources, completedRootSegment: null, + rootDidFlush: false, abortableTasks: abortSet, pingedTasks: pingedTasks, clientRenderedBoundaries: [], @@ -337,6 +354,7 @@ function createSuspenseBoundary( byteSize: 0, fallbackAbortableTasks, errorDigest: null, + resources: createBoundaryResources(), }; } @@ -562,6 +580,12 @@ function renderSuspenseBoundary( // we're writing to. If something suspends, it'll spawn new suspended task with that context. task.blockedBoundary = newBoundary; task.blockedSegment = contentRootSegment; + if (enableFloat) { + setCurrentlyRenderingBoundaryResources( + request.resources, + newBoundary.resources, + ); + } try { // We use the safe form because we don't handle suspending here. Only error handling. renderNode(request, task, content); @@ -572,6 +596,11 @@ function renderSuspenseBoundary( contentRootSegment.textEmbedded, ); contentRootSegment.status = COMPLETED; + if (enableFloat) { + if (newBoundary.pendingTasks === 0) { + hoistCompletedBoundaryResources(request, newBoundary); + } + } queueCompletedSegment(newBoundary, contentRootSegment); if (newBoundary.pendingTasks === 0) { // This must have been the last segment we were waiting on. This boundary is now complete. @@ -592,6 +621,12 @@ function renderSuspenseBoundary( // We don't need to schedule any task because we know the parent has written yet. // We do need to fallthrough to create the fallback though. } finally { + if (enableFloat) { + setCurrentlyRenderingBoundaryResources( + request.resources, + parentBoundary ? parentBoundary.resources : null, + ); + } task.blockedBoundary = parentBoundary; task.blockedSegment = parentSegment; } @@ -619,6 +654,19 @@ function renderSuspenseBoundary( popComponentStackInDEV(task); } +function hoistCompletedBoundaryResources( + request: Request, + completedBoundary: SuspenseBoundary, +): void { + if (!request.rootDidFlush) { + // The Shell has not flushed yet. we can hoist Resources for this boundary + // all the way to the Root. + hoistResourcesToRoot(request.resources, completedBoundary.resources); + } + // We don't hoist if the root already flushed because late resources will be hoisted + // as boundaries flush +} + function renderBackupSuspenseBoundary( request: Request, task: Task, @@ -644,6 +692,7 @@ function renderHostElement( ): void { pushBuiltInComponentStackInDEV(task, type); const segment = task.blockedSegment; + const children = pushStartInstance( segment.chunks, request.preamble, @@ -651,10 +700,12 @@ function renderHostElement( props, request.responseState, segment.formatContext, + segment.lastPushedText, ); segment.lastPushedText = false; const prevContext = segment.formatContext; segment.formatContext = getChildFormatContext(prevContext, type, props); + // We use the non-destructive form because if something suspends, we still // need to pop back up and finish this subtree of HTML. renderNode(request, task, children); @@ -1733,11 +1784,15 @@ function finishedTask( queueCompletedSegment(boundary, segment); } } + if (enableFloat) { + hoistCompletedBoundaryResources(request, boundary); + } if (boundary.parentFlushed) { // The segment might be part of a segment that didn't flush yet, but if the boundary's // parent flushed, we need to schedule the boundary to be emitted. request.completedBoundaries.push(boundary); } + // We can now cancel any pending task on the fallback since we won't need to show it anymore. // This needs to happen after we read the parentFlushed flags because aborting can finish // work which can trigger user code, which can start flushing, which can change those flags. @@ -1774,6 +1829,14 @@ function finishedTask( } function retryTask(request: Request, task: Task): void { + prepareToRender(request.resources); + if (enableFloat) { + const blockedBoundary = task.blockedBoundary; + setCurrentlyRenderingBoundaryResources( + request.resources, + blockedBoundary ? blockedBoundary.resources : null, + ); + } const segment = task.blockedSegment; if (segment.status !== PENDING) { // We completed this by other means before we had a chance to retry it. @@ -1825,9 +1888,13 @@ function retryTask(request: Request, task: Task): void { erroredTask(request, task.blockedBoundary, segment, x); } } finally { + if (enableFloat) { + setCurrentlyRenderingBoundaryResources(request.resources, null); + } if (__DEV__) { currentTaskInDEV = prevTaskInDEV; } + cleanupAfterRender(); } } @@ -1900,6 +1967,7 @@ function flushSubtree( const chunks = segment.chunks; let chunkIdx = 0; const children = segment.children; + for (let childIdx = 0; childIdx < children.length; childIdx++) { const nextChild = children[childIdx]; // Write all the chunks up until the next child. @@ -1998,6 +2066,9 @@ function flushSegment( return writeEndPendingSuspenseBoundary(destination, request.responseState); } else { + if (enableFloat) { + hoistResources(request.resources, boundary.resources); + } // We can inline this boundary's content as a complete boundary. writeStartCompletedSuspenseBoundary(destination, request.responseState); @@ -2019,6 +2090,25 @@ function flushSegment( } } +function flushInitialResources( + destination: Destination, + resources: Resources, + responseState: ResponseState, +): void { + writeInitialResources(destination, resources, responseState); +} + +function flushImmediateResources( + destination: Destination, + request: Request, +): void { + writeImmediateResources( + destination, + request.resources, + request.responseState, + ); +} + function flushClientRenderedBoundary( request: Request, destination: Destination, @@ -2054,6 +2144,12 @@ function flushCompletedBoundary( destination: Destination, boundary: SuspenseBoundary, ): boolean { + if (enableFloat) { + setCurrentlyRenderingBoundaryResources( + request.resources, + boundary.resources, + ); + } const completedSegments = boundary.completedSegments; let i = 0; for (; i < completedSegments.length; i++) { @@ -2067,6 +2163,7 @@ function flushCompletedBoundary( request.responseState, boundary.id, boundary.rootSegmentID, + boundary.resources, ); } @@ -2075,6 +2172,12 @@ function flushPartialBoundary( destination: Destination, boundary: SuspenseBoundary, ): boolean { + if (enableFloat) { + setCurrentlyRenderingBoundaryResources( + request.resources, + boundary.resources, + ); + } const completedSegments = boundary.completedSegments; let i = 0; for (; i < completedSegments.length; i++) { @@ -2138,8 +2241,6 @@ function flushCompletedQueues( // that item fully and then yield. At that point we remove the already completed // items up until the point we completed them. - // TODO: Emit preloading. - let i; const completedRootSegment = request.completedRootSegment; if (completedRootSegment !== null) { @@ -2150,15 +2251,24 @@ function flushCompletedQueues( // we expect the preamble to be tiny and will ignore backpressure writeChunk(destination, preamble[i]); } + + flushInitialResources( + destination, + request.resources, + request.responseState, + ); } flushSegment(request, destination, completedRootSegment); request.completedRootSegment = null; + request.rootDidFlush = true; writeCompletedRoot(destination, request.responseState); } else { - // We haven't flushed the root yet so we don't need to check boundaries further down + // We haven't flushed the root yet so we don't need to check any other branches further down return; } + } else if (enableFloat && request.rootDidFlush) { + flushImmediateResources(destination, request); } // We emit client rendering instructions for already emitted boundaries first. diff --git a/packages/react-server/src/forks/ReactServerFormatConfig.custom.js b/packages/react-server/src/forks/ReactServerFormatConfig.custom.js index ecb3218ea1dad..6bd8fe3dee04c 100644 --- a/packages/react-server/src/forks/ReactServerFormatConfig.custom.js +++ b/packages/react-server/src/forks/ReactServerFormatConfig.custom.js @@ -26,6 +26,8 @@ declare var $$$hostConfig: any; export opaque type Destination = mixed; // eslint-disable-line no-undef export opaque type ResponseState = mixed; +export opaque type Resources = mixed; +export opaque type BoundaryResources = mixed; export opaque type FormatContext = mixed; export opaque type SuspenseBoundaryID = mixed; @@ -66,3 +68,17 @@ export const writeCompletedBoundaryInstruction = $$$hostConfig.writeCompletedBoundaryInstruction; export const writeClientRenderBoundaryInstruction = $$$hostConfig.writeClientRenderBoundaryInstruction; + +// ------------------------- +// Resources +// ------------------------- +export const writeInitialResources = $$$hostConfig.writeInitialResources; +export const writeImmediateResources = $$$hostConfig.writeImmediateResources; +export const hoistResources = $$$hostConfig.hoistResources; +export const hoistResourcesToRoot = $$$hostConfig.hoistResourcesToRoot; +export const prepareToRender = $$$hostConfig.prepareToRender; +export const cleanupAfterRender = $$$hostConfig.cleanupAfterRender; +export const createResources = $$$hostConfig.createResources; +export const createBoundaryResources = $$$hostConfig.createBoundaryResources; +export const setCurrentlyRenderingBoundaryResources = + $$$hostConfig.setCurrentlyRenderingBoundaryResources; diff --git a/packages/react-test-renderer/src/ReactTestHostConfig.js b/packages/react-test-renderer/src/ReactTestHostConfig.js index dfccf3e9ef0fc..8140fe7a0bbd9 100644 --- a/packages/react-test-renderer/src/ReactTestHostConfig.js +++ b/packages/react-test-renderer/src/ReactTestHostConfig.js @@ -46,6 +46,7 @@ export * from 'react-reconciler/src/ReactFiberHostConfigWithNoPersistence'; export * from 'react-reconciler/src/ReactFiberHostConfigWithNoHydration'; export * from 'react-reconciler/src/ReactFiberHostConfigWithNoTestSelectors'; export * from 'react-reconciler/src/ReactFiberHostConfigWithNoMicrotasks'; +export * from 'react-reconciler/src/ReactFiberHostConfigWithNoResources'; const NO_CONTEXT = {}; const UPDATE_SIGNAL = {}; diff --git a/packages/react-test-renderer/src/ReactTestRenderer.js b/packages/react-test-renderer/src/ReactTestRenderer.js index ae2e532b270eb..f8d8b8ca3a37a 100644 --- a/packages/react-test-renderer/src/ReactTestRenderer.js +++ b/packages/react-test-renderer/src/ReactTestRenderer.js @@ -27,6 +27,7 @@ import { FunctionComponent, ClassComponent, HostComponent, + HostResource, HostPortal, HostText, HostRoot, @@ -196,6 +197,7 @@ function toTree(node: ?Fiber) { instance: null, rendered: childrenToTree(node.child), }; + case HostResource: case HostComponent: { return { nodeType: 'host', @@ -302,7 +304,7 @@ class ReactTestInstance { } get instance() { - if (this._fiber.tag === HostComponent) { + if (this._fiber.tag === HostComponent || this._fiber.tag === HostResource) { return getPublicInstance(this._fiber.stateNode); } else { return this._fiber.stateNode; diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 23889e317b192..1112e284460e0 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -426,5 +426,11 @@ "438": "An unsupported type was passed to use(): %s", "439": "We didn't expect to see a forward reference. This is a bug in the React Server.", "440": "A function wrapped in useEvent can't be called during rendering.", - "441": "An error occurred in the Server Components render. The specific message is omitted in production builds to avoid leaking sensitive details. A digest property is included on this error instance which may provide additional details about the nature of the error." + "441": "An error occurred in the Server Components render. The specific message is omitted in production builds to avoid leaking sensitive details. A digest property is included on this error instance which may provide additional details about the nature of the error.", + "442": "The current renderer does not support Resources. This error is likely caused by a bug in React. Please file an issue.", + "443": "acquireResource encountered a resource type it did not expect: \"%s\". this is a bug in React.", + "444": "getResource encountered a resource type it did not expect: \"%s\". this is a bug in React.", + "445": "\"currentResources\" was expected to exist. This is a bug in React.", + "446": "\"currentDocument\" was expected to exist. This is a bug in React.", + "447": "While attempting to insert a Resource, React expected the Document to contain a head element but it was not found." } diff --git a/scripts/shared/inlinedHostConfigs.js b/scripts/shared/inlinedHostConfigs.js index 9d3cfe9b35517..2cd0f8400341e 100644 --- a/scripts/shared/inlinedHostConfigs.js +++ b/scripts/shared/inlinedHostConfigs.js @@ -130,7 +130,12 @@ module.exports = [ 'react-server-dom-relay/server', 'react-server-dom-relay/src/ReactDOMServerFB.js', ], - paths: ['react-dom', 'react-dom-bindings', 'react-server-dom-relay'], + paths: [ + 'react-dom', + 'react-dom-bindings', + 'react-server-dom-relay', + 'shared/ReactDOMSharedInternals', + ], isFlowTyped: true, isServerSupported: true, }, From 589485d0bacf889c970094a89d2bf8d5be9cf6c8 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Tue, 27 Sep 2022 23:40:33 -0700 Subject: [PATCH 02/35] determine preamble while flushing and make postamble resilient to async render order --- .../src/server/ReactDOMServerFormatConfig.js | 87 +++------ .../ReactDOMServerLegacyFormatConfig.js | 1 + .../src/__tests__/ReactDOMFizzServer-test.js | 165 ++++++++++++------ .../server/ReactNativeServerFormatConfig.js | 3 +- .../src/ReactNoopServer.js | 1 - packages/react-server/src/ReactFizzServer.js | 67 +++++-- .../forks/ReactServerFormatConfig.custom.js | 1 + 7 files changed, 201 insertions(+), 124 deletions(-) diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js index 6f4ba0791785f..140741447397c 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js @@ -7,6 +7,7 @@ * @flow */ +import type {ArrayWithPreamble} from 'react-server/src/ReactFizzServer'; import type {ReactNodeList} from 'shared/ReactTypes'; import type {Resources, BoundaryResources} from './ReactDOMFloatServer'; export type {Resources, BoundaryResources}; @@ -93,6 +94,8 @@ export type ResponseState = { ... }; +export const emptyChunk = stringToPrecomputedChunk(''); + const startInlineScript = stringToPrecomputedChunk(''); @@ -283,25 +286,6 @@ export function getChildFormatContext( return createFormatContext(HTML_MODE, null); } if (parentContext.insertionMode === ROOT_HTML_MODE) { - switch (type) { - case 'html': { - return parentContext; - } - case 'head': - case 'title': - case 'base': - case 'link': - case 'style': - case 'meta': - case 'script': - case 'noscript': - case 'template': { - break; - } - default: { - parentContext.preambleOpen = false; - } - } // We've emitted the root and is now in plain HTML mode. return createFormatContext(HTML_MODE, null); } @@ -1316,40 +1300,33 @@ function pushStartTitle( } function pushStartHead( - target: Array, - preamble: Array, + target: ArrayWithPreamble, props: Object, tag: string, responseState: ResponseState, formatContext: FormatContext, ): ReactNodeList { - // Preamble type is nullable for feature off cases but is guaranteed when feature is on - target = - enableFloat && - formatContext.insertionMode === ROOT_HTML_MODE && - formatContext.preambleOpen - ? preamble - : target; - - return pushStartGenericElement(target, props, tag, responseState); + const children = pushStartGenericElement(target, props, tag, responseState); + target._preambleIndex = target.length; + return children; } function pushStartHtml( - target: Array, - preamble: Array, + target: ArrayWithPreamble, props: Object, tag: string, responseState: ResponseState, formatContext: FormatContext, ): ReactNodeList { if (formatContext.insertionMode === ROOT_HTML_MODE) { - target = enableFloat && formatContext.preambleOpen ? preamble : target; // If we're rendering the html tag and we're at the root (i.e. not in foreignObject) // then we also emit the DOCTYPE as part of the root content as a convenience for // rendering the whole document. target.push(DOCTYPE); } - return pushStartGenericElement(target, props, tag, responseState); + const children = pushStartGenericElement(target, props, tag, responseState); + target._preambleIndex = target.length; + return children; } function pushStartGenericElement( @@ -1567,8 +1544,7 @@ function startChunkForTag(tag: string): PrecomputedChunk { const DOCTYPE: PrecomputedChunk = stringToPrecomputedChunk(''); export function pushStartInstance( - target: Array, - preamble: Array, + target: ArrayWithPreamble, type: string, props: Object, responseState: ResponseState, @@ -1663,23 +1639,9 @@ export function pushStartInstance( } // Preamble start tags case 'head': - return pushStartHead( - target, - preamble, - props, - type, - responseState, - formatContext, - ); + return pushStartHead(target, props, type, responseState, formatContext); case 'html': { - return pushStartHtml( - target, - preamble, - props, - type, - responseState, - formatContext, - ); + return pushStartHtml(target, props, type, responseState, formatContext); } default: { if (type.indexOf('-') === -1 && typeof props.is !== 'string') { @@ -1722,17 +1684,24 @@ export function pushEndInstance( case 'track': case 'wbr': { // No close tag needed. - break; + return; } // Postamble end tags - case 'body': - case 'html': - target = enableFloat ? postamble : target; - // Intentional fallthrough - default: { - target.push(endTag1, stringToChunk(type), endTag2); + case 'body': { + if (enableFloat) { + postamble.unshift(endTag1, stringToChunk(type), endTag2); + return; + } + break; } + case 'html': + if (enableFloat) { + postamble.push(endTag1, stringToChunk(type), endTag2); + return; + } + break; } + target.push(endTag1, stringToChunk(type), endTag2); } export function writeCompletedRoot( diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js b/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js index dcd1178b5e1a7..30f9502cba1d6 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js @@ -110,6 +110,7 @@ export { setCurrentlyRenderingBoundaryResources, prepareToRender, cleanupAfterRender, + emptyChunk, } from './ReactDOMServerFormatConfig'; import {stringToChunk} from 'react-server/src/ReactServerStreamConfig'; diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index fec985bd20299..01c2dadcd35a3 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -4320,70 +4320,129 @@ describe('ReactDOMFizzServer', () => { }); // @gate enableFloat - it('emits html and head start tags (the preamble) before other content if rendered in the shell', async () => { + it('can emit the preamble even if the head renders asynchronously', async () => { + function AsyncNoOutput() { + readText('nooutput'); + return null; + } + function AsyncHead() { + readText('head'); + return ( + + a title + + ); + } + function AsyncBody() { + readText('body'); + return ( + + + hello + + ); + } await actIntoEmptyDocument(() => { const {pipe} = ReactDOMFizzServer.renderToPipeableStream( - <> - a title - - - a body - - , + + + + + , ); pipe(writable); }); + await actIntoEmptyDocument(() => { + resolveText('body'); + }); + await actIntoEmptyDocument(() => { + resolveText('nooutput'); + }); + // We need to use actIntoEmptyDocument because act assumes that buffered + // content should be fake streamed into the body which is normally true + // but in this test the entire shell was delayed and we need the initial + // construction to be done to get the parsing right + await actIntoEmptyDocument(() => { + resolveText('head'); + }); expect(getVisibleChildren(document)).toEqual( - - - a title + + + + a title - a body + hello , ); + }); - // Hydrate the same thing on the client. We expect this to still fail because is not a Resource - // and is unmatched on hydration - const errors = []; - ReactDOMClient.hydrateRoot( - document, - <> - <title data-baz="baz">a title - - - a body - - , - { - onRecoverableError: (err, errInfo) => { - errors.push(err.message); - }, - }, - ); - expect(() => { - try { - expect(() => { - expect(Scheduler).toFlushWithoutYielding(); - }).toThrow('Invalid insertion of HTML node in #document node.'); - } catch (e) { - console.log('e', e); - } - }).toErrorDev( - [ - 'Warning: Expected server HTML to contain a matching in <#document>.', - 'Warning: An error occurred during hydration. The server HTML was replaced with client content in <#document>.', - 'Warning: validateDOMNesting(...): <title> cannot appear as a child of <#document>', - ], - {withoutStack: 1}, + // @gate enableFloat + it('does not emit as preamble after the first non-preamble chunk', async () => { + function AsyncNoOutput() { + readText('nooutput'); + return null; + } + function AsyncHead() { + readText('head'); + return ( + <head data-foo="foo"> + <title>a title + + ); + } + function AsyncBody() { + readText('body'); + return ( + + + hello + + ); + } + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + + + , + ); + pipe(writable); + }); + await actIntoEmptyDocument(() => { + resolveText('body'); + }); + await actIntoEmptyDocument(() => { + resolveText('nooutput'); + }); + // We need to use actIntoEmptyDocument because act assumes that buffered + // content should be fake streamed into the body which is normally true + // but in this test the entire shell was delayed and we need the initial + // construction to be done to get the parsing right + await actIntoEmptyDocument(() => { + resolveText('head'); + }); + // This assertion is a little strange. The html open tag is part of the preamble + // but since the next chunk will be the body open tag which is not preamble it + // emits resources. The browser understands that the link is part of the head and + // constructs the head implicitly which is why it does not have the data-foo attribute. + // When the head finally streams in it is inside the body rather than after it because the + // body closing tag is part of the postamble which stays open until the entire request + // has flushed. This is how the browser would interpret a late head arriving after the + // the body closing tag so while strange it is the expected behavior. One other oddity + // is that in body is elided by html parsers so we end up with just an inlined + // style tag. + expect(getVisibleChildren(document)).toEqual( + + + + + + hello + a title + + , ); - expect(errors).toEqual([ - 'Hydration failed because the initial UI does not match what was rendered on the server.', - 'There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.', - ]); - expect(getVisibleChildren(document)).toEqual(); - expect(() => { - expect(Scheduler).toFlushWithoutYielding(); - }).toThrow('The node to be removed is not a child of this node.'); }); // @gate enableFloat diff --git a/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js b/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js index 9dc15791afb79..a7f2c61dc035f 100644 --- a/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js +++ b/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js @@ -65,6 +65,8 @@ export type ResponseState = { nextSuspenseID: number, }; +export const emptyChunk = stringToPrecomputedChunk(''); + // Allows us to keep track of what we've already written so we can refer back to it. export function createResponseState(): ResponseState { return { @@ -140,7 +142,6 @@ export function pushTextInstance( export function pushStartInstance( target: Array, - preamble: Array, type: string, props: Object, responseState: ResponseState, diff --git a/packages/react-noop-renderer/src/ReactNoopServer.js b/packages/react-noop-renderer/src/ReactNoopServer.js index ef966b3f0d9b5..3b264673bfa71 100644 --- a/packages/react-noop-renderer/src/ReactNoopServer.js +++ b/packages/react-noop-renderer/src/ReactNoopServer.js @@ -116,7 +116,6 @@ const ReactNoopServer = ReactFizzServer({ }, pushStartInstance( target: Array, - preamble: Array, type: string, props: Object, ): ReactNodeList { diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index f1ca5b4203f40..ec6f8fb331f7f 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -74,6 +74,7 @@ import { setCurrentlyRenderingBoundaryResources, createResources, createBoundaryResources, + emptyChunk, } from './ReactServerFormatConfig'; import { constructClassInstance, @@ -182,12 +183,18 @@ const ERRORED = 4; type Root = null; +export type ArrayWithPreamble = Array & {_preambleIndex?: number}; + +function asArrayWithPreamble(a: Array): ArrayWithPreamble { + return (a: any); +} + type Segment = { status: 0 | 1 | 2 | 3 | 4, parentFlushed: boolean, // typically a segment will be flushed by its parent, except if its parent was already flushed id: number, // starts as 0 and is lazily assigned if the parent flushes early +index: number, // the index within the parent's chunks or 0 at the root - +chunks: Array, + +chunks: ArrayWithPreamble, +children: Array, // The context that this segment was created in. formatContext: FormatContext, @@ -220,7 +227,6 @@ export opaque type Request = { clientRenderedBoundaries: Array, // Errored or client rendered but not yet flushed. completedBoundaries: Array, // Completed but not yet fully flushed boundaries to show. partialBoundaries: Array, // Partially completed boundaries that can flush its segments early. - +preamble: Array, // Chunks that need to be emitted before any segment chunks. +postamble: Array, // Chunks that need to be emitted after segments, waiting for all pending root tasks to finish // onError is called when an error happens anywhere in the tree. It might recover. // The return string is used in production primarily to avoid leaking internals, secondarily to save bytes. @@ -297,7 +303,6 @@ export function createRequest( clientRenderedBoundaries: [], completedBoundaries: [], partialBoundaries: [], - preamble: [], postamble: [], onError: onError === undefined ? defaultErrorHandler : onError, onAllReady: onAllReady === undefined ? noop : onAllReady, @@ -406,7 +411,7 @@ function createPendingSegment( id: -1, // lazily assigned later index, parentFlushed: false, - chunks: [], + chunks: asArrayWithPreamble([]), children: [], formatContext, boundary, @@ -695,7 +700,6 @@ function renderHostElement( const children = pushStartInstance( segment.chunks, - request.preamble, type, props, request.responseState, @@ -1945,6 +1949,53 @@ export function performWork(request: Request): void { } } +function flushPreamble( + request: Request, + destination: Destination, + segment: Segment, +): boolean { + const boundary = segment.boundary; + if (boundary) { + // Preamble needs to be part of the shell + return true; + } + if (segment.status === COMPLETED) { + const chunks = segment.chunks; + const preambleIndex = chunks._preambleIndex || 0; + let chunkIdx = 0; + const children = segment.children; + + for (let childIdx = 0; childIdx < children.length; childIdx++) { + const nextChild = children[childIdx]; + // Write all the chunks up until the next child. + for (; chunkIdx < nextChild.index; chunkIdx++) { + if (chunkIdx < preambleIndex) { + writeChunk(destination, chunks[chunkIdx]); + chunks[chunkIdx] = emptyChunk; + } else { + // We have encountered a chunk that isn't preamble and must halt + return true; + } + } + if (flushPreamble(request, destination, nextChild)) { + // this recursive call halted, propagate + return true; + } + } + for (; chunkIdx < chunks.length; chunkIdx++) { + if (chunkIdx < preambleIndex) { + writeChunk(destination, chunks[chunkIdx]); + chunks[chunkIdx] = emptyChunk; + } else { + // We have encountered a chunk that isn't preamble and must halt + return true; + } + } + return false; + } + return true; +} + function flushSubtree( request: Request, destination: Destination, @@ -2246,11 +2297,7 @@ function flushCompletedQueues( if (completedRootSegment !== null) { if (request.pendingRootTasks === 0) { if (enableFloat) { - const preamble = request.preamble; - for (i = 0; i < preamble.length; i++) { - // we expect the preamble to be tiny and will ignore backpressure - writeChunk(destination, preamble[i]); - } + flushPreamble(request, destination, completedRootSegment); flushInitialResources( destination, diff --git a/packages/react-server/src/forks/ReactServerFormatConfig.custom.js b/packages/react-server/src/forks/ReactServerFormatConfig.custom.js index 6bd8fe3dee04c..5dca75ada7c5a 100644 --- a/packages/react-server/src/forks/ReactServerFormatConfig.custom.js +++ b/packages/react-server/src/forks/ReactServerFormatConfig.custom.js @@ -68,6 +68,7 @@ export const writeCompletedBoundaryInstruction = $$$hostConfig.writeCompletedBoundaryInstruction; export const writeClientRenderBoundaryInstruction = $$$hostConfig.writeClientRenderBoundaryInstruction; +export const emptyChunk = $$$hostConfig.emptyChunk; // ------------------------- // Resources From b562d26a72d8e7461966f5883c45c008f4ce120a Mon Sep 17 00:00:00 2001 From: Josh Story Date: Wed, 28 Sep 2022 13:01:33 -0700 Subject: [PATCH 03/35] refactor render prep --- packages/react-art/src/ReactARTHostConfig.js | 8 ++++++++ .../react-native-renderer/src/ReactFabricHostConfig.js | 8 ++++++++ .../react-native-renderer/src/ReactNativeHostConfig.js | 8 ++++++++ packages/react-noop-renderer/src/createReactNoop.js | 4 ++-- .../src/ReactFiberHostConfigWithNoResources.js | 2 -- .../src/__tests__/ReactFiberHostContext-test.internal.js | 8 ++++---- .../src/forks/ReactFiberHostConfig.custom.js | 4 ++-- .../src/ReactFlightDOMRelayServerHostConfig.js | 3 +++ packages/react-test-renderer/src/ReactTestHostConfig.js | 8 ++++++++ 9 files changed, 43 insertions(+), 10 deletions(-) diff --git a/packages/react-art/src/ReactARTHostConfig.js b/packages/react-art/src/ReactARTHostConfig.js index 9bf674bfc64ab..aa3925cdbe817 100644 --- a/packages/react-art/src/ReactARTHostConfig.js +++ b/packages/react-art/src/ReactARTHostConfig.js @@ -456,3 +456,11 @@ export function detachDeletedInstance(node: Instance): void { export function requestPostPaintCallback(callback: (time: number) => void) { // noop } + +export function prepareRendererToRender(container: Container): void { + // noop +} + +export function resetRendererAfterRender(): void { + // noop +} diff --git a/packages/react-native-renderer/src/ReactFabricHostConfig.js b/packages/react-native-renderer/src/ReactFabricHostConfig.js index 9b591ccf94baf..d78339b8128ea 100644 --- a/packages/react-native-renderer/src/ReactFabricHostConfig.js +++ b/packages/react-native-renderer/src/ReactFabricHostConfig.js @@ -616,3 +616,11 @@ export function detachDeletedInstance(node: Instance): void { export function requestPostPaintCallback(callback: (time: number) => void) { // noop } + +export function prepareRendererToRender(container: Container): void { + // noop +} + +export function resetRendererAfterRender() { + // noop +} diff --git a/packages/react-native-renderer/src/ReactNativeHostConfig.js b/packages/react-native-renderer/src/ReactNativeHostConfig.js index 8728f4daf5614..5deae0672e6ba 100644 --- a/packages/react-native-renderer/src/ReactNativeHostConfig.js +++ b/packages/react-native-renderer/src/ReactNativeHostConfig.js @@ -515,3 +515,11 @@ export function detachDeletedInstance(node: Instance): void { export function requestPostPaintCallback(callback: (time: number) => void) { // noop } + +export function prepareRendererToRender(container: Container): void { + // noop +} + +export function resetRendererAfterRender(): void { + // noop +} diff --git a/packages/react-noop-renderer/src/createReactNoop.js b/packages/react-noop-renderer/src/createReactNoop.js index 7665b5d06dfba..46464a37f81c6 100644 --- a/packages/react-noop-renderer/src/createReactNoop.js +++ b/packages/react-noop-renderer/src/createReactNoop.js @@ -478,8 +478,8 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { const endTime = Scheduler.unstable_now(); callback(endTime); }, - prepareToRender() {}, - cleanupAfterRender() {}, + prepareRendererToRender() {}, + resetRendererAfterRender() {}, }; const hostConfig = useMutation diff --git a/packages/react-reconciler/src/ReactFiberHostConfigWithNoResources.js b/packages/react-reconciler/src/ReactFiberHostConfigWithNoResources.js index a63d1d24218df..0b194ef099448 100644 --- a/packages/react-reconciler/src/ReactFiberHostConfigWithNoResources.js +++ b/packages/react-reconciler/src/ReactFiberHostConfigWithNoResources.js @@ -25,5 +25,3 @@ export const isHostResourceType = shim; export const getResource = shim; export const acquireResource = shim; export const releaseResource = shim; -export const prepareToRender = shim; -export const cleanupAfterRender = shim; diff --git a/packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js b/packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js index f41296621ba9a..64912d57ef92c 100644 --- a/packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js @@ -66,8 +66,8 @@ describe('ReactFiberHostContext', () => { getCurrentEventPriority: function() { return DefaultEventPriority; }, - prepareToRender: function() {}, - cleanupAfterRender: function() {}, + prepareRendererToRender: function() {}, + resetRendererAfterRender: function() {}, supportsMutation: true, requestPostPaintCallback: function() {}, }); @@ -133,8 +133,8 @@ describe('ReactFiberHostContext', () => { return DefaultEventPriority; }, requestPostPaintCallback: function() {}, - prepareToRender: function() {}, - cleanupAfterRender: function() {}, + prepareRendererToRender: function() {}, + resetRendererAfterRender: function() {}, supportsMutation: true, }); diff --git a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js index 261d96400cd9a..2f1520dbb5e21 100644 --- a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js @@ -68,6 +68,8 @@ export const getInstanceFromScope = $$$hostConfig.getInstanceFromScope; export const getCurrentEventPriority = $$$hostConfig.getCurrentEventPriority; export const detachDeletedInstance = $$$hostConfig.detachDeletedInstance; export const requestPostPaintCallback = $$$hostConfig.requestPostPaintCallback; +export const prepareRendererToRender = $$$hostConfig.prepareRendererToRender; +export const resetRendererAfterRender = $$$hostConfig.resetRendererAfterRender; // ------------------- // Microtasks @@ -198,5 +200,3 @@ export const isHostResourceType = $$$hostConfig.isHostResourceType; export const getResource = $$$hostConfig.getResource; export const acquireResource = $$$hostConfig.acquireResource; export const releaseResource = $$$hostConfig.releaseResource; -export const prepareToRender = $$$hostConfig.prepareToRender; -export const cleanupAfterRender = $$$hostConfig.cleanupAfterRender; diff --git a/packages/react-server-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js b/packages/react-server-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js index fc205a445013f..1474eabea498c 100644 --- a/packages/react-server-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js +++ b/packages/react-server-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js @@ -214,3 +214,6 @@ export {close}; export function closeWithError(destination: Destination, error: mixed): void { close(destination); } + +export function prepareRendererToRender(container: Container) {} +export function resetRendererAfterRender() {} diff --git a/packages/react-test-renderer/src/ReactTestHostConfig.js b/packages/react-test-renderer/src/ReactTestHostConfig.js index 8140fe7a0bbd9..1db038e411286 100644 --- a/packages/react-test-renderer/src/ReactTestHostConfig.js +++ b/packages/react-test-renderer/src/ReactTestHostConfig.js @@ -322,3 +322,11 @@ export function logRecoverableError(error: mixed): void { export function requestPostPaintCallback(callback: (time: number) => void) { // noop } + +export function prepareRendererToRender(container: Container): void { + // noop +} + +export function resetRendererAfterRender(): void { + // noop +} From ca984490f0691d81b65b0e8d9be382d88d674044 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Wed, 28 Sep 2022 13:11:52 -0700 Subject: [PATCH 04/35] setCurrentlyRenderingBoundaryResources => setCurrentlyRenderingBoundaryResourcesTarget --- .../src/server/ReactDOMFloatServer.js | 6 +++--- .../src/server/ReactDOMServerFormatConfig.js | 2 +- .../src/server/ReactDOMServerLegacyFormatConfig.js | 2 +- .../src/server/ReactNativeServerFormatConfig.js | 2 +- .../react-noop-renderer/src/ReactNoopServer.js | 2 +- packages/react-server/src/ReactFizzServer.js | 14 +++++++------- .../src/forks/ReactServerFormatConfig.custom.js | 4 ++-- 7 files changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js b/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js index eead934e95d52..15fe64cccdbec 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js +++ b/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js @@ -98,7 +98,7 @@ export function mergeBoundaryResources( source.forEach(resource => target.add(resource)); } -let currentResources: ?Resources = null; +let currentResources: null | Resources = null; let previousDispatcher = null; export function prepareToRender(resources: Resources) { @@ -108,9 +108,9 @@ export function prepareToRender(resources: Resources) { ReactDOMSharedInternals.Dispatcher.current = Dispatcher; } -export function setCurrentlyRenderingBoundaryResources( +export function setCurrentlyRenderingBoundaryResourcesTarget( resources: Resources, - boundaryResources: ?BoundaryResources, + boundaryResources: null | BoundaryResources, ) { resources.boundaryResources = boundaryResources; } diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js index 140741447397c..e6819f4f446f0 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js @@ -69,7 +69,7 @@ import { export { createResources, createBoundaryResources, - setCurrentlyRenderingBoundaryResources, + setCurrentlyRenderingBoundaryResourcesTarget, hoistResources, hoistResourcesToRoot, } from './ReactDOMFloatServer'; diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js b/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js index 30f9502cba1d6..7c04ac48442c7 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js @@ -107,7 +107,7 @@ export { writeImmediateResources, hoistResources, hoistResourcesToRoot, - setCurrentlyRenderingBoundaryResources, + setCurrentlyRenderingBoundaryResourcesTarget, prepareToRender, cleanupAfterRender, emptyChunk, diff --git a/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js b/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js index a7f2c61dc035f..e0924388ba0ae 100644 --- a/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js +++ b/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js @@ -346,7 +346,7 @@ export function prepareToRender(resources: Resources) {} export function cleanupAfterRender() {} export function createResources() {} export function createBoundaryResources() {} -export function setCurrentlyRenderingBoundaryResources( +export function setCurrentlyRenderingBoundaryResourcesTarget( resources: Resources, boundaryResources: ?BoundaryResources, ) {} diff --git a/packages/react-noop-renderer/src/ReactNoopServer.js b/packages/react-noop-renderer/src/ReactNoopServer.js index 3b264673bfa71..662668b2fddfa 100644 --- a/packages/react-noop-renderer/src/ReactNoopServer.js +++ b/packages/react-noop-renderer/src/ReactNoopServer.js @@ -277,7 +277,7 @@ const ReactNoopServer = ReactFizzServer({ return null; }, - setCurrentlyRenderingBoundaryResources(resources: BoundaryResources) {}, + setCurrentlyRenderingBoundaryResourcesTarget(resources: BoundaryResources) {}, prepareToRender() {}, cleanupAfterRender() {}, diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index ec6f8fb331f7f..13af610f32f22 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -71,7 +71,7 @@ import { hoistResourcesToRoot, prepareToRender, cleanupAfterRender, - setCurrentlyRenderingBoundaryResources, + setCurrentlyRenderingBoundaryResourcesTarget, createResources, createBoundaryResources, emptyChunk, @@ -586,7 +586,7 @@ function renderSuspenseBoundary( task.blockedBoundary = newBoundary; task.blockedSegment = contentRootSegment; if (enableFloat) { - setCurrentlyRenderingBoundaryResources( + setCurrentlyRenderingBoundaryResourcesTarget( request.resources, newBoundary.resources, ); @@ -627,7 +627,7 @@ function renderSuspenseBoundary( // We do need to fallthrough to create the fallback though. } finally { if (enableFloat) { - setCurrentlyRenderingBoundaryResources( + setCurrentlyRenderingBoundaryResourcesTarget( request.resources, parentBoundary ? parentBoundary.resources : null, ); @@ -1836,7 +1836,7 @@ function retryTask(request: Request, task: Task): void { prepareToRender(request.resources); if (enableFloat) { const blockedBoundary = task.blockedBoundary; - setCurrentlyRenderingBoundaryResources( + setCurrentlyRenderingBoundaryResourcesTarget( request.resources, blockedBoundary ? blockedBoundary.resources : null, ); @@ -1893,7 +1893,7 @@ function retryTask(request: Request, task: Task): void { } } finally { if (enableFloat) { - setCurrentlyRenderingBoundaryResources(request.resources, null); + setCurrentlyRenderingBoundaryResourcesTarget(request.resources, null); } if (__DEV__) { currentTaskInDEV = prevTaskInDEV; @@ -2196,7 +2196,7 @@ function flushCompletedBoundary( boundary: SuspenseBoundary, ): boolean { if (enableFloat) { - setCurrentlyRenderingBoundaryResources( + setCurrentlyRenderingBoundaryResourcesTarget( request.resources, boundary.resources, ); @@ -2224,7 +2224,7 @@ function flushPartialBoundary( boundary: SuspenseBoundary, ): boolean { if (enableFloat) { - setCurrentlyRenderingBoundaryResources( + setCurrentlyRenderingBoundaryResourcesTarget( request.resources, boundary.resources, ); diff --git a/packages/react-server/src/forks/ReactServerFormatConfig.custom.js b/packages/react-server/src/forks/ReactServerFormatConfig.custom.js index 5dca75ada7c5a..08a2b45ec9424 100644 --- a/packages/react-server/src/forks/ReactServerFormatConfig.custom.js +++ b/packages/react-server/src/forks/ReactServerFormatConfig.custom.js @@ -81,5 +81,5 @@ export const prepareToRender = $$$hostConfig.prepareToRender; export const cleanupAfterRender = $$$hostConfig.cleanupAfterRender; export const createResources = $$$hostConfig.createResources; export const createBoundaryResources = $$$hostConfig.createBoundaryResources; -export const setCurrentlyRenderingBoundaryResources = - $$$hostConfig.setCurrentlyRenderingBoundaryResources; +export const setCurrentlyRenderingBoundaryResourcesTarget = + $$$hostConfig.setCurrentlyRenderingBoundaryResourcesTarget; From 72bbd2cb9c5f74bc07c6c50076cd5c5611a0b337 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Wed, 28 Sep 2022 13:28:59 -0700 Subject: [PATCH 05/35] fixes --- packages/react-dom/src/__tests__/ReactDOMFloat-test.js | 1 + .../src/ReactFlightDOMRelayServerHostConfig.js | 3 --- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js index 86273e90876de..ffb12d16f2f91 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js @@ -229,6 +229,7 @@ describe('ReactDOMFloat', () => { return readText(text); } + // @gate enableFloat it('errors if the document does not contain a head when inserting a resource', async () => { document.head.parentNode.removeChild(document.head); const root = ReactDOMClient.createRoot(document); diff --git a/packages/react-server-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js b/packages/react-server-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js index 1474eabea498c..fc205a445013f 100644 --- a/packages/react-server-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js +++ b/packages/react-server-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js @@ -214,6 +214,3 @@ export {close}; export function closeWithError(destination: Destination, error: mixed): void { close(destination); } - -export function prepareRendererToRender(container: Container) {} -export function resetRendererAfterRender() {} From 52b1388c471b296687eb171d90e0ba50c33d26ac Mon Sep 17 00:00:00 2001 From: Josh Story Date: Wed, 28 Sep 2022 16:31:15 -0700 Subject: [PATCH 06/35] add react-dom to fizz entry externals --- scripts/rollup/bundles.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index f93225d9d15da..74522d9406f74 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -308,7 +308,7 @@ const bundles = [ global: 'ReactDOMServer', minifyWithProdErrorCodes: true, wrapWithModuleBoundaries: false, - externals: ['react'], + externals: ['react', 'react-dom'], }, { bundleTypes: [NODE_DEV, NODE_PROD], @@ -318,7 +318,7 @@ const bundles = [ global: 'ReactDOMServer', minifyWithProdErrorCodes: false, wrapWithModuleBoundaries: false, - externals: ['react', 'util'], + externals: ['react', 'util', 'react-dom'], }, { bundleTypes: __EXPERIMENTAL__ ? [FB_WWW_DEV, FB_WWW_PROD] : [], @@ -327,7 +327,7 @@ const bundles = [ global: 'ReactDOMServerStreaming', minifyWithProdErrorCodes: false, wrapWithModuleBoundaries: false, - externals: ['react'], + externals: ['react', 'react-dom'], }, /******* React DOM Fizz Static *******/ @@ -338,7 +338,7 @@ const bundles = [ global: 'ReactDOMStatic', minifyWithProdErrorCodes: true, wrapWithModuleBoundaries: false, - externals: ['react'], + externals: ['react', 'react-dom'], }, { bundleTypes: __EXPERIMENTAL__ ? [NODE_DEV, NODE_PROD] : [], @@ -348,7 +348,7 @@ const bundles = [ global: 'ReactDOMStatic', minifyWithProdErrorCodes: false, wrapWithModuleBoundaries: false, - externals: ['react', 'util', 'stream'], + externals: ['react', 'util', 'stream', 'react-dom'], }, /******* React Server DOM Webpack Writer *******/ From d3bffc20e4eb2cba7d5b962e2223fce71dec3266 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Wed, 28 Sep 2022 16:58:02 -0700 Subject: [PATCH 07/35] refactor default dispatcher initialization --- .../src/client/ReactDOMFloatClient.js | 11 ++++------- .../react-dom-bindings/src/shared/ReactDOMFloat.js | 2 +- packages/react-dom/src/ReactDOMSharedInternals.js | 4 ++++ 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js b/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js index ebe0f72c3cb9c..a383d38af7203 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js +++ b/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js @@ -112,13 +112,10 @@ export function cleanupAfterRenderResources() { previousDispatcher = null; } -const ReactDOMClientDispatcher = {preload, preinit}; - -// For client we set the dispatcher to the default client dispatcher. In mixed environments (like tests) -// this will be temporarily overwritten when another runtime is rendering. We do this so we can handle -// event callbacks with the client dispatcher. In the future we will likely have different client -// dispatchers for when we are in render mode vs non-render mode -ReactDOMSharedInternals.Dispatcher.current = ReactDOMClientDispatcher; +// 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 -> FloatClient -> Internals so this doesn't introduce a new one. +export const ReactDOMClientDispatcher = {preload, preinit}; // global maps of Resources const preloadResources: Map = new Map(); diff --git a/packages/react-dom-bindings/src/shared/ReactDOMFloat.js b/packages/react-dom-bindings/src/shared/ReactDOMFloat.js index 998d4c2a4f0f0..3fabd9fb922f3 100644 --- a/packages/react-dom-bindings/src/shared/ReactDOMFloat.js +++ b/packages/react-dom-bindings/src/shared/ReactDOMFloat.js @@ -1,4 +1,4 @@ -import ReactDOMSharedInternals from '../ReactDOMSharedInternals'; +import ReactDOMSharedInternals from 'shared/ReactDOMSharedInternals'; export function preinit() { const dispatcher = ReactDOMSharedInternals.Dispatcher.current; diff --git a/packages/react-dom/src/ReactDOMSharedInternals.js b/packages/react-dom/src/ReactDOMSharedInternals.js index 3fb74360285ed..0c79e97baa5ec 100644 --- a/packages/react-dom/src/ReactDOMSharedInternals.js +++ b/packages/react-dom/src/ReactDOMSharedInternals.js @@ -18,6 +18,7 @@ import { getFiberCurrentPropsFromNode, } from 'react-dom-bindings/src/client/ReactDOMComponentTree'; import Dispatcher from 'react-dom-bindings/src/shared/ReactDOMDispatcher'; +import {ReactDOMClientDispatcher} from 'react-dom-bindings/src/client/ReactDOMFloatClient'; const Internals = { usingClientEntryPoint: false, @@ -32,4 +33,7 @@ const Internals = { Dispatcher, }; +// Set the default dispatcher to the Client dispatcher +Dispatcher.current = ReactDOMClientDispatcher; + export default Internals; From 24a863852098b93d363b00c82a1dac74abc92fe9 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Wed, 28 Sep 2022 22:45:36 -0700 Subject: [PATCH 08/35] set default dispatcher in creatRoot and hydra teRoot --- .../react-dom/src/ReactDOMSharedInternals.js | 4 ---- .../src/__tests__/ReactDOMFloat-test.js | 16 +++++++++++++--- packages/react-dom/src/client/ReactDOMRoot.js | 12 ++++++++++++ 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/packages/react-dom/src/ReactDOMSharedInternals.js b/packages/react-dom/src/ReactDOMSharedInternals.js index 0c79e97baa5ec..3fb74360285ed 100644 --- a/packages/react-dom/src/ReactDOMSharedInternals.js +++ b/packages/react-dom/src/ReactDOMSharedInternals.js @@ -18,7 +18,6 @@ import { getFiberCurrentPropsFromNode, } from 'react-dom-bindings/src/client/ReactDOMComponentTree'; import Dispatcher from 'react-dom-bindings/src/shared/ReactDOMDispatcher'; -import {ReactDOMClientDispatcher} from 'react-dom-bindings/src/client/ReactDOMFloatClient'; const Internals = { usingClientEntryPoint: false, @@ -33,7 +32,4 @@ const Internals = { Dispatcher, }; -// Set the default dispatcher to the Client dispatcher -Dispatcher.current = ReactDOMClientDispatcher; - export default Internals; diff --git a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js index ffb12d16f2f91..68c5029d6fb2c 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js @@ -387,8 +387,13 @@ describe('ReactDOMFloat', () => { }); // @gate enableFloat - it('inserts a preload resource when called in module scope', async () => { + it('inserts a preload resource when called in module scope if a root has already been created', async () => { + // The requirement that a root be created has to do with bootstrapping the dispatcher. + // We are intentionally avoiding setting it to the default via import due to cycles and + // we are trying to avoid doing a mutable initailation in module scope. ReactDOM.preload('foo', {as: 'style'}); + ReactDOMClient.createRoot(container); + ReactDOM.preload('bar', {as: 'style'}); // We need to use global.document because preload falls back // to the window.document global when no other documents have been used // The way the JSDOM runtim is created for these tests the local document @@ -396,7 +401,7 @@ describe('ReactDOMFloat', () => { expect(getVisibleChildren(global.document)).toEqual( - + , @@ -526,7 +531,12 @@ describe('ReactDOMFloat', () => { // @gate enableFloat it('inserts a preload resource when called in module scope', async () => { + // The requirement that a root be created has to do with bootstrapping the dispatcher. + // We are intentionally avoiding setting it to the default via import due to cycles and + // we are trying to avoid doing a mutable initailation in module scope. ReactDOM.preinit('foo', {as: 'style'}); + ReactDOMClient.hydrateRoot(container, null); + ReactDOM.preinit('bar', {as: 'style'}); // We need to use global.document because preload falls back // to the window.document global when no other documents have been used // The way the JSDOM runtim is created for these tests the local document @@ -534,7 +544,7 @@ describe('ReactDOMFloat', () => { expect(getVisibleChildren(global.document)).toEqual( - + , diff --git a/packages/react-dom/src/client/ReactDOMRoot.js b/packages/react-dom/src/client/ReactDOMRoot.js index cea9be01a3a19..399e8153c24b9 100644 --- a/packages/react-dom/src/client/ReactDOMRoot.js +++ b/packages/react-dom/src/client/ReactDOMRoot.js @@ -13,6 +13,8 @@ import type { TransitionTracingCallbacks, } from 'react-reconciler/src/ReactInternalTypes'; +import ReactDOMSharedInternals from '../ReactDOMSharedInternals'; +import {ReactDOMClientDispatcher} from 'react-dom-bindings/src/client/ReactDOMFloatClient'; import {queueExplicitHydrationTarget} from 'react-dom-bindings/src/events/ReactDOMEventReplaying'; import {REACT_ELEMENT_TYPE} from 'shared/ReactSymbols'; import {enableFloat} from 'shared/ReactFeatureFlags'; @@ -241,6 +243,11 @@ export function createRoot( : container; listenToAllSupportedEvents(rootContainerElement); + if (enableFloat) { + // Set the default dispatcher to the client dispatcher + ReactDOMSharedInternals.Dispatcher.current = ReactDOMClientDispatcher; + } + return new ReactDOMRoot(root); } @@ -329,6 +336,11 @@ export function hydrateRoot( } } + if (enableFloat) { + // Set the default dispatcher to the client dispatcher + ReactDOMSharedInternals.Dispatcher.current = ReactDOMClientDispatcher; + } + return new ReactDOMHydrationRoot(root); } From f06f1ad1759b36e7c8ed51f589fce4b1e2de6019 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Wed, 28 Sep 2022 23:13:20 -0700 Subject: [PATCH 09/35] clarify render start and stop function names --- .../react-dom-bindings/src/server/ReactDOMFloatServer.js | 4 ++-- .../src/server/ReactDOMServerFormatConfig.js | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js b/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js index 15fe64cccdbec..a7f3de5cb0d24 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js +++ b/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js @@ -101,7 +101,7 @@ export function mergeBoundaryResources( let currentResources: null | Resources = null; let previousDispatcher = null; -export function prepareToRender(resources: Resources) { +export function prepareToRenderResources(resources: Resources) { currentResources = resources; previousDispatcher = ReactDOMSharedInternals.Dispatcher.current; @@ -115,7 +115,7 @@ export function setCurrentlyRenderingBoundaryResourcesTarget( resources.boundaryResources = boundaryResources; } -export function cleanupAfterRender() { +export function finishRenderingResources() { currentResources = null; ReactDOMSharedInternals.Dispatcher.current = previousDispatcher; diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js index e6819f4f446f0..419042b94326f 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js @@ -62,8 +62,8 @@ import sanitizeURL from '../shared/sanitizeURL'; import isArray from 'shared/isArray'; import { - prepareToRender as prepareToRenderImpl, - cleanupAfterRender as cleanupAfterRenderImpl, + prepareToRenderResources, + finishRenderingResources, resourcesFromLink, } from './ReactDOMFloatServer'; export { @@ -2902,9 +2902,9 @@ function writeStyleResourceAttribute( } export function prepareToRender(resources: Resources) { - prepareToRenderImpl(resources); + prepareToRenderResources(resources); } export function cleanupAfterRender() { - cleanupAfterRenderImpl(); + finishRenderingResources(); } From 3793c2e0913d1c9e2cef777cc57d679211bc07e1 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Wed, 28 Sep 2022 23:18:29 -0700 Subject: [PATCH 10/35] make TODO proper TODO and cleanup comments --- .../react-dom-bindings/src/server/ReactDOMFloatServer.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js b/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js index a7f3de5cb0d24..210093e5e4bbf 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js +++ b/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js @@ -69,15 +69,16 @@ export type Resources = { boundaryResources: ?BoundaryResources, }; +// @TODO add bootstrap script to implicit preloads export function createResources(): Resources { return { // persistent - preloadsMap: new Map(), // preloadResources - stylesMap: new Map(), // styleResources + preloadsMap: new Map(), + stylesMap: new Map(), // cleared on flush - explicitPreloads: new Set(), // explicitPreloads - implicitPreloads: new Set(), // add bootstrap script to implicit preloads + explicitPreloads: new Set(), + implicitPreloads: new Set(), precedences: new Map(), // like a module global for currently rendering boundary From 16be133c2c69637479cc95e396f340e4d0583fb6 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Wed, 28 Sep 2022 23:38:57 -0700 Subject: [PATCH 11/35] integrate push/pop dispatcher with renderer prep / reset --- .../react-reconciler/src/ReactFiberWorkLoop.old.js | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js index 3545a49ca8854..45b8a60adac64 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js @@ -1769,7 +1769,8 @@ function handleThrow(root, thrownValue): void { } } -function pushDispatcher() { +function pushDispatcher(container) { + prepareRendererToRender(container); const prevDispatcher = ReactCurrentDispatcher.current; ReactCurrentDispatcher.current = ContextOnlyDispatcher; if (prevDispatcher === null) { @@ -1783,6 +1784,7 @@ function pushDispatcher() { } function popDispatcher(prevDispatcher) { + resetRendererAfterRender(); ReactCurrentDispatcher.current = prevDispatcher; } @@ -1852,8 +1854,7 @@ export function renderHasNotSuspendedYet(): boolean { function renderRootSync(root: FiberRoot, lanes: Lanes) { const prevExecutionContext = executionContext; executionContext |= RenderContext; - const prevDispatcher = pushDispatcher(); - prepareRendererToRender(root.containerInfo); + const prevDispatcher = pushDispatcher(root.containerInfo); // If the root or lanes have changed, throw out the existing stack // and prepare a fresh one. Otherwise we'll continue where we left off. @@ -1900,7 +1901,6 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) { executionContext = prevExecutionContext; popDispatcher(prevDispatcher); - resetRendererAfterRender(); if (workInProgress !== null) { // This is a sync render, so we should have finished the whole tree. @@ -1954,8 +1954,7 @@ function workLoopSync() { function renderRootConcurrent(root: FiberRoot, lanes: Lanes) { const prevExecutionContext = executionContext; executionContext |= RenderContext; - const prevDispatcher = pushDispatcher(); - prepareRendererToRender(root.containerInfo); + const prevDispatcher = pushDispatcher(root.containerInfo); // If the root or lanes have changed, throw out the existing stack // and prepare a fresh one. Otherwise we'll continue where we left off. @@ -2008,7 +2007,6 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) { resetContextDependencies(); popDispatcher(prevDispatcher); - resetRendererAfterRender(); executionContext = prevExecutionContext; if (__DEV__) { From 0dc98d2fe684e84d39e941a5932b6036edc9bcb1 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Wed, 28 Sep 2022 23:40:26 -0700 Subject: [PATCH 12/35] forks --- .../react-reconciler/src/ReactFiberWorkLoop.new.js | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js index 5db8bb57e9b89..c72de5c18d239 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js @@ -1769,7 +1769,8 @@ function handleThrow(root, thrownValue): void { } } -function pushDispatcher() { +function pushDispatcher(container) { + prepareRendererToRender(container); const prevDispatcher = ReactCurrentDispatcher.current; ReactCurrentDispatcher.current = ContextOnlyDispatcher; if (prevDispatcher === null) { @@ -1783,6 +1784,7 @@ function pushDispatcher() { } function popDispatcher(prevDispatcher) { + resetRendererAfterRender(); ReactCurrentDispatcher.current = prevDispatcher; } @@ -1852,8 +1854,7 @@ export function renderHasNotSuspendedYet(): boolean { function renderRootSync(root: FiberRoot, lanes: Lanes) { const prevExecutionContext = executionContext; executionContext |= RenderContext; - const prevDispatcher = pushDispatcher(); - prepareRendererToRender(root.containerInfo); + const prevDispatcher = pushDispatcher(root.containerInfo); // If the root or lanes have changed, throw out the existing stack // and prepare a fresh one. Otherwise we'll continue where we left off. @@ -1900,7 +1901,6 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) { executionContext = prevExecutionContext; popDispatcher(prevDispatcher); - resetRendererAfterRender(); if (workInProgress !== null) { // This is a sync render, so we should have finished the whole tree. @@ -1954,8 +1954,7 @@ function workLoopSync() { function renderRootConcurrent(root: FiberRoot, lanes: Lanes) { const prevExecutionContext = executionContext; executionContext |= RenderContext; - const prevDispatcher = pushDispatcher(); - prepareRendererToRender(root.containerInfo); + const prevDispatcher = pushDispatcher(root.containerInfo); // If the root or lanes have changed, throw out the existing stack // and prepare a fresh one. Otherwise we'll continue where we left off. @@ -2008,7 +2007,6 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) { resetContextDependencies(); popDispatcher(prevDispatcher); - resetRendererAfterRender(); executionContext = prevExecutionContext; if (__DEV__) { From 9956a7740dc46934b1cd01740961b3a23195df0f Mon Sep 17 00:00:00 2001 From: Josh Story Date: Thu, 29 Sep 2022 10:35:16 -0700 Subject: [PATCH 13/35] wip insertstyles --- .../src/server/ReactDOMServerFormatConfig.js | 310 +++++++++--------- 1 file changed, 153 insertions(+), 157 deletions(-) diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js index 419042b94326f..d553c957e4b2b 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js @@ -2224,15 +2224,12 @@ const completeSegmentFunction = // const completeBoundaryFunction = // '$RC=function(){function h(e,f){var a=document.getElementById(e),c=document.getElementById(f);c.parentNode.removeChild(c);if(a){a=a.previousSibling;var k=a.parentNode,b=a.nextSibling,g=0;do{if(b&&8===b.nodeType){var d=b.data;if("/$"===d)if(0===g)break;else g--;else"$"!==d&&"$?"!==d&&"$!"!==d||g++}d=b.nextSibling;k.removeChild(b);b=d}while(b);for(;c.firstChild;)k.insertBefore(c.firstChild,b);a.data="$";a._reactRetry&&a._reactRetry()}}return function(e,f,a){if(a&&(a=$RR(a)))return Promise.all(a).then(h.bind(null,e,f))["catch"](function(c){return console.log("caught",c)});h(e,f)}}()' const styleInsertionFunction = - '$RR=function(){function r(f){var g=f._p=(new Promise(function(b,a){f.onload=b;f.onerror=a})).then(function(){g.s="l"},function(b){g.s="e";throw b;});return g}for(var q=new Map,m=new Map,h,n,p=document,t=p.querySelectorAll("link[data-rprec]"),u=0;n=t[u++];)m.set(n.dataset.rprec,h=n);return function(f){for(var g=0,b,a,k,e,c,d;a=f[g++];){var l=0;k=a[l++];if(c=q.get(k))"l"!==c.s&&(b?b.push(c):b=[c]);else{d=p.createElement("link");d.href=k;d.rel="stylesheet";for(d.dataset.rprec=e=a[l++];c=a[l++];)d.setAttribute(c,a[l++]);c=r(d);q.set(k,c);b?b.push(c):b=[c];a=m.get(e)||h;a===h&&(h=d);m.set(e,d);a?a.parentNode.insertBefore(d,a.nextSibling):(e=p.head,e.insertBefore(d,e.firstChild))}}return b}}();'; -// const styleInsertionFunction = `$RR = (function() { -// const resourceMap = new Map(); + '$RM=new Map;function $RR(l,m,t){function r(n){this.s=n}for(var p=new Map,q=document,g,f,e=q.querySelectorAll("link[data-rprec]"),d=0;f=e[d++];)p.set(f.dataset.rprec,g=f);f=0;e=[];for(var c,h,b,a;c=t[f++];){var k=0;h=c[k++];if(b=$RM.get(h))"l"!==b.s&&e.push(b);else{a=q.createElement("link");a.href=h;a.rel="stylesheet";for(a.dataset.rprec=d=c[k++];b=c[k++];)a.setAttribute(b,c[k++]);b=a._p=new Promise(function(n,u){a.onload=n;a.onerror=u});b.then(r.bind(b,"l"),r.bind(b,"e"));$RM.set(h,b);e.push(b);c=p.get(d)||g;c===g&&(g=a);p.set(d,a);c?c.parentNode.insertBefore(a,c.nextSibling):(d=q.head,d.insertBefore(a,d.firstChild))}}e.length?Promise.all(e).then($RC.bind(null,l,m,null),$RC.bind(null,l,m,"Resource failed to load")):$RC(l,m)};'; +// const styleInsertionFunction = `$RM = new Map(); +// function $RR(suspenseBoundaryID, contentID, styles) { // const precedences = new Map(); -// // We omit rel stylesheet because only stylesheets should have data-prec attribute -// // and we can concievably use another kind of link to act as a placeholder for a -// // precedence that does not yet have any resources. +// const thisDocument = document; // let lastResource, node; -// let thisDocument = document; // // Seed the precedence list with existing resources // let nodes = thisDocument.querySelectorAll('link[data-rprec]'); @@ -2240,158 +2237,150 @@ const styleInsertionFunction = // precedences.set(node.dataset.rprec, lastResource = node); // } -// function getPromise(resourceEl) { -// let p = resourceEl._p = new Promise((re, rj) => { -// resourceEl.onload = re; -// resourceEl.onerror = rj; -// }).then(() => { -// p.s = 'l'; -// }, (e) => { -// p.s = 'e'; -// throw e; -// }); -// return p; -// } - -// return function insertStyle(styles) { -// let i = 0; -// let dependencies, style, href, precedence, attr, loadingState, resourceEl; +// let i = 0; +// let dependencies = []; +// let style, href, precedence, attr, loadingState, resourceEl; -// while (style = styles[i++]) { -// let j = 0; -// href = style[j++]; -// // We check if this resource is already in our resourceMap and reuse it if so. -// // If it is already loaded we don't return it as a depenendency since there is nothing -// // to wait for -// loadingState = resourceMap.get(href); -// if (loadingState) { -// if (loadingState.s !== 'l') { -// dependencies ? dependencies.push(loadingState) : dependencies = [loadingState]; -// } -// continue; -// } +// function setStatus(s) { +// this.s = s; +// } -// // We construct our new resource element, looping over remaining attributes if any -// // setting them to the Element. -// resourceEl = thisDocument.createElement("link"); -// resourceEl.href = href; -// resourceEl.rel = 'stylesheet'; -// resourceEl.dataset.rprec = precedence = style[j++]; -// while(attr = style[j++]) { -// resourceEl.setAttribute(attr, style[j++]); +// while (style = styles[i++]) { +// let j = 0; +// href = style[j++]; +// // We check if this resource is already in our resourceMap and reuse it if so. +// // If it is already loaded we don't return it as a depenendency since there is nothing +// // to wait for +// loadingState = $RM.get(href); +// if (loadingState) { +// if (loadingState.s !== 'l') { +// dependencies.push(loadingState); // } +// continue; +// } -// // We stash a pending promise in our map by href which will resolve or reject -// // when the underlying resource loads or errors. We add it to the dependencies -// // array to be returned. -// loadingState = getPromise(resourceEl); -// resourceMap.set(href, loadingState); -// dependencies ? dependencies.push(loadingState) : dependencies = [loadingState]; +// // We construct our new resource element, looping over remaining attributes if any +// // setting them to the Element. +// resourceEl = thisDocument.createElement("link"); +// resourceEl.href = href; +// resourceEl.rel = 'stylesheet'; +// resourceEl.dataset.rprec = precedence = style[j++]; +// while(attr = style[j++]) { +// resourceEl.setAttribute(attr, style[j++]); +// } -// // The prior style resource is the last one placed at a given -// // precedence or the last resource itself which may be null. -// // We grab this value and then update the last resource for this -// // precedence to be the inserted element, updating the lastResource -// // pointer if needed. -// let prior = precedences.get(precedence) || lastResource; -// if (prior === lastResource) { -// lastResource = resourceEl -// } -// precedences.set(precedence, resourceEl) +// // We stash a pending promise in our map by href which will resolve or reject +// // when the underlying resource loads or errors. We add it to the dependencies +// // array to be returned. +// loadingState = resourceEl._p = new Promise((re, rj) => { +// resourceEl.onload = re; +// resourceEl.onerror = rj; +// }) +// loadingState.then( +// setStatus.bind(loadingState, 'l'), +// setStatus.bind(loadingState, 'e') +// ); +// $RM.set(href, loadingState); +// dependencies.push(loadingState); + +// // The prior style resource is the last one placed at a given +// // precedence or the last resource itself which may be null. +// // We grab this value and then update the last resource for this +// // precedence to be the inserted element, updating the lastResource +// // pointer if needed. +// let prior = precedences.get(precedence) || lastResource; +// if (prior === lastResource) { +// lastResource = resourceEl +// } +// precedences.set(precedence, resourceEl) -// // Finally, we insert the newly constructed instance at an appropriate location -// // in the Document. -// if (prior) { -// prior.parentNode.insertBefore(resourceEl, prior.nextSibling); -// } else { -// let head = thisDocument.head; -// head.insertBefore(resourceEl, head.firstChild); -// } +// // Finally, we insert the newly constructed instance at an appropriate location +// // in the Document. +// if (prior) { +// prior.parentNode.insertBefore(resourceEl, prior.nextSibling); +// } else { +// let head = thisDocument.head; +// head.insertBefore(resourceEl, head.firstChild); // } -// return dependencies; // } -// })()`; -const completeBoundaryFunction = - '$RC=function(){function f(a,c,d){var b=document.getElementById(a);if(b){a=b.previousSibling;if(c){d=a.parentNode;b=a.nextSibling;var g=0;do{if(b&&8===b.nodeType){var e=b.data;if("/$"===e)if(0===g)break;else g--;else"$"!==e&&"$?"!==e&&"$!"!==e||g++}e=b.nextSibling;d.removeChild(b);b=e}while(b);for(;c.firstChild;)d.insertBefore(c.firstChild,b);a.data="$"}else a.data="$!",b.setAttribute("data-dgst",d);a._reactRetry&&a._reactRetry()}}return function(a,c,d){c=document.getElementById(c);c.parentNode.removeChild(c);if(d&&(d=$RR(d)))return Promise.all(d).then(f.bind(null,a,c,null),f.bind(null,a,null,"Resource failed to load"));f(a,c)}}();'; -// const completeBoundaryFunction = `$RC = (function() { -// function flipBoundary(suspenseBoundaryID, contentNode, errorDigest) { -// // Find the fallback's first element. -// const suspenseIdNode = document.getElementById(suspenseBoundaryID); -// if (!suspenseIdNode) { -// // The user must have already navigated away from this tree. -// // E.g. because the parent was hydrated. That's fine there's nothing to do -// // but we have to make sure that we already deleted the container node. -// return; -// } -// // Find the boundary around the fallback. This is always the previous node. -// const suspenseNode = suspenseIdNode.previousSibling; -// if (contentNode) { -// // Clear all the existing children. This is complicated because -// // there can be embedded Suspense boundaries in the fallback. -// // This is similar to clearSuspenseBoundary in ReactDOMHostConfig. -// // TODO: We could avoid this if we never emitted suspense boundaries in fallback trees. -// // They never hydrate anyway. However, currently we support incrementally loading the fallback. -// const parentInstance = suspenseNode.parentNode; -// let node = suspenseNode.nextSibling; -// let depth = 0; -// do { -// if (node && node.nodeType === 8) { -// const data = node.data; -// if (data === '/$') { -// if (depth === 0) { -// break; -// } else { -// depth--; -// } -// } else if ( -// data === '$' || -// data === '$?' || -// data === '$!' -// ) { -// depth++; -// } -// } +// if (dependencies.length) { +// Promise.all(dependencies).then( +// $RC.bind(null, suspenseBoundaryID, contentID, null), +// $RC.bind(null, suspenseBoundaryID, contentID, "Resource failed to load") +// ); +// } else { +// $RC(suspenseBoundaryID, contentID); +// } +// }`; +// const completeBoundaryFunction = +// '$RC=function(){function f(a,c,d){var b=document.getElementById(a);if(b){a=b.previousSibling;if(c){d=a.parentNode;b=a.nextSibling;var g=0;do{if(b&&8===b.nodeType){var e=b.data;if("/$"===e)if(0===g)break;else g--;else"$"!==e&&"$?"!==e&&"$!"!==e||g++}e=b.nextSibling;d.removeChild(b);b=e}while(b);for(;c.firstChild;)d.insertBefore(c.firstChild,b);a.data="$"}else a.data="$!",b.setAttribute("data-dgst",d);a._reactRetry&&a._reactRetry()}}return function(a,c,d){c=document.getElementById(c);c.parentNode.removeChild(c);if(d&&(d=$RR(d)))return Promise.all(d).then(f.bind(null,a,c,null),f.bind(null,a,null,"Resource failed to load"));f(a,c)}}();'; +const completeBoundaryFunction = `function $RC(suspenseBoundaryID, contentID, errorDigest) { + const contentNode = document.getElementById(contentID); + // We'll detach the content node so that regardless of what happens next we don't leave in the tree. + // This might also help by not causing recalcing each time we move a child from here to the target. + contentNode.parentNode.removeChild(contentNode); + + // Find the fallback's first element. + const suspenseIdNode = document.getElementById(suspenseBoundaryID); + if (!suspenseIdNode) { + // The user must have already navigated away from this tree. + // E.g. because the parent was hydrated. That's fine there's nothing to do + // but we have to make sure that we already deleted the container node. + return; + } + // Find the boundary around the fallback. This is always the previous node. + const suspenseNode = suspenseIdNode.previousSibling; + + if (!errorDigest) { + // Clear all the existing children. This is complicated because + // there can be embedded Suspense boundaries in the fallback. + // This is similar to clearSuspenseBoundary in ReactDOMHostConfig. + // TODO: We could avoid this if we never emitted suspense boundaries in fallback trees. + // They never hydrate anyway. However, currently we support incrementally loading the fallback. + const parentInstance = suspenseNode.parentNode; + let node = suspenseNode.nextSibling; + let depth = 0; + do { + if (node && node.nodeType === 8) { + const data = node.data; + if (data === '/$') { + if (depth === 0) { + break; + } else { + depth--; + } + } else if ( + data === '$' || + data === '$?' || + data === '$!' + ) { + depth++; + } + } -// const nextNode = node.nextSibling; -// parentInstance.removeChild(node); -// node = nextNode; -// } while (node); + const nextNode = node.nextSibling; + parentInstance.removeChild(node); + node = nextNode; + } while (node); -// const endOfBoundary = node; + const endOfBoundary = node; -// // Insert all the children from the contentNode between the start and end of suspense boundary. -// while (contentNode.firstChild) { -// parentInstance.insertBefore(contentNode.firstChild, endOfBoundary); -// } + // Insert all the children from the contentNode between the start and end of suspense boundary. + while (contentNode.firstChild) { + parentInstance.insertBefore(contentNode.firstChild, endOfBoundary); + } -// suspenseNode.data = '$'; -// } else { -// suspenseNode.data = '$!'; -// suspenseIdNode.setAttribute('data-dgst', errorDigest) -// } + suspenseNode.data = '$'; + } else { + suspenseNode.data = '$!'; + suspenseIdNode.setAttribute('data-dgst', errorDigest) + } -// if (suspenseNode._reactRetry) { -// suspenseNode._reactRetry(); -// } -// } -// return function completeBoundary(suspenseBoundaryID, contentID, styleResources){ -// const contentNode = document.getElementById(contentID); -// // We'll detach the content node so that regardless of what happens next we don't leave in the tree. -// // This might also help by not causing recalcing each time we move a child from here to the target. -// contentNode.parentNode.removeChild(contentNode); -// if (styleResources) { -// const p = $RR(styleResources); -// if (p) { -// return Promise.all(p).then( -// flipBoundary.bind(null, suspenseBoundaryID, contentNode, null), -// flipBoundary.bind(null, suspenseBoundaryID, null, "Resource failed to load"), -// ); -// } -// } -// flipBoundary(suspenseBoundaryID, contentNode); -// } -// })()`; + if (suspenseNode._reactRetry) { + suspenseNode._reactRetry(); + } +}`; const clientRenderFunction = 'function $RX(b,c,d,e){var a=document.getElementById(b);a&&(b=a.previousSibling,b.data="$!",a=a.dataset,c&&(a.dgst=c),d&&(a.msg=d),e&&(a.stck=e),b._reactRetry&&b._reactRetry())}'; @@ -2426,15 +2415,18 @@ export function writeCompletedSegmentInstruction( } const completeBoundaryScript1FullWithStyleInsertion = stringToPrecomputedChunk( - styleInsertionFunction + ';' + completeBoundaryFunction + ';$RC("', + styleInsertionFunction + ';' + completeBoundaryFunction + ';$RR("', ); const completeBoundaryScript1Full = stringToPrecomputedChunk( completeBoundaryFunction + ';$RC("', ); -const completeBoundaryScript1PartialWithStyleInsertion = stringToPrecomputedChunk( - styleInsertionFunction + ';$RC("', +const completeBoundaryWithStylesScript1PartialWithStyleInsertion = stringToPrecomputedChunk( + styleInsertionFunction + ';$RR("', ); const completeBoundaryScript1Partial = stringToPrecomputedChunk('$RC("'); +const completeBoundaryWithStylesScript1Partial = stringToPrecomputedChunk( + '$RR("', +); const completeBoundaryScript2 = stringToPrecomputedChunk('","'); const completeBoundaryScript3 = stringToPrecomputedChunk('"'); const completeBoundaryScript4 = stringToPrecomputedChunk(')'); @@ -2466,16 +2458,20 @@ export function writeCompletedBoundaryInstruction( writeChunk(destination, completeBoundaryScript1Full); } } else { - if ( - enableFloat && - hasStyleDependencies && - !responseState.sentStyleInsertionFunction - ) { - responseState.sentStyleInsertionFunction = true; - writeChunk(destination, completeBoundaryScript1PartialWithStyleInsertion); + if (enableFloat && hasStyleDependencies) { + if (!responseState.sentStyleInsertionFunction) { + responseState.sentStyleInsertionFunction = true; + writeChunk( + destination, + completeBoundaryWithStylesScript1PartialWithStyleInsertion, + ); + } else { + writeChunk(destination, completeBoundaryWithStylesScript1Partial); + } + } else { + // Future calls can just reuse the same function. + writeChunk(destination, completeBoundaryScript1Partial); } - // Future calls can just reuse the same function. - writeChunk(destination, completeBoundaryScript1Partial); } if (boundaryID === null) { From eec88a39d69513465482808ff944b7f0d7a85028 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Thu, 29 Sep 2022 14:08:57 -0700 Subject: [PATCH 14/35] wip --- .../src/client/ReactDOMFloatClient.js | 5 ++- .../src/client/ReactDOMHostConfig.js | 2 +- .../react-dom/src/ReactDOMSharedInternals.js | 35 +++++++------------ .../src/__tests__/ReactDOMFloat-test.js | 4 ++- packages/react-dom/src/client/ReactDOM.js | 24 +++++++++++-- packages/react-dom/src/client/ReactDOMRoot.js | 19 +++++----- packages/react-server-dom-relay/package.json | 3 +- 7 files changed, 54 insertions(+), 38 deletions(-) diff --git a/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js b/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js index a383d38af7203..05bb317c93a29 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js +++ b/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js @@ -9,6 +9,7 @@ import type {Instance} from './ReactDOMHostConfig'; import ReactDOMSharedInternals from 'shared/ReactDOMSharedInternals.js'; +const {Dispatcher} = ReactDOMSharedInternals; import { validateUnmatchedLinkResourceProps, validatePreloadResourceDifference, @@ -494,7 +495,7 @@ function createStyleResource( loaded: false, error: false, ownerDocument, - instance: existingEl, + instance: null, }; styleResources.set(href, resource); @@ -739,6 +740,8 @@ function insertStyleInstance( // must exist. ((prior.parentNode: any): Node).insertBefore(instance, prior.nextSibling); } else { + // @TODO call getRootNode on root.container. if it is a Document, insert into head + // if it is a ShadowRoot insert it into the root node. const parent = ownerDocument.head; if (parent) { parent.insertBefore(instance, parent.firstChild); diff --git a/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js b/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js index c62f0e7f49473..e73bb9306ea90 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js @@ -461,7 +461,7 @@ export function commitMount( case 'html': case 'head': case 'body': { - if (enableFloat) { + if (false && enableFloat) { restoreAllStylesResources(); } } diff --git a/packages/react-dom/src/ReactDOMSharedInternals.js b/packages/react-dom/src/ReactDOMSharedInternals.js index 3fb74360285ed..12cb9dc000134 100644 --- a/packages/react-dom/src/ReactDOMSharedInternals.js +++ b/packages/react-dom/src/ReactDOMSharedInternals.js @@ -7,29 +7,20 @@ * @flow */ -import {batchedUpdates} from 'react-reconciler/src/ReactFiberReconciler'; -import { - enqueueStateRestore, - restoreStateIfNeeded, -} from 'react-dom-bindings/src/events/ReactDOMControlledComponent'; -import { - getInstanceFromNode, - getNodeFromInstance, - getFiberCurrentPropsFromNode, -} from 'react-dom-bindings/src/client/ReactDOMComponentTree'; -import Dispatcher from 'react-dom-bindings/src/shared/ReactDOMDispatcher'; +type InternalsType = { + usingClientEntryPoint: boolean, + Events: [any, any, any, any, any, any], + Dispatcher: { + current: mixed, + }, +}; -const Internals = { +const Internals: InternalsType = ({ usingClientEntryPoint: false, - Events: [ - getInstanceFromNode, - getNodeFromInstance, - getFiberCurrentPropsFromNode, - enqueueStateRestore, - restoreStateIfNeeded, - batchedUpdates, - ], - Dispatcher, -}; + Events: null, + Dispatcher: { + current: null, + }, +}: any); export default Internals; diff --git a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js index 68c5029d6fb2c..c02d46973eb5e 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js @@ -818,8 +818,10 @@ describe('ReactDOMFloat', () => { ); }); + // Disabling for now since we are going to live with not having a restore step while we consider + // HostSingletons or other solutions // @gate enableFloat - it('retains styles in head through head remounts', async () => { + xit('retains styles in head through head remounts', async () => { const root = ReactDOMClient.createRoot(document); root.render( diff --git a/packages/react-dom/src/client/ReactDOM.js b/packages/react-dom/src/client/ReactDOM.js index 3134f220aa669..849bc283e01db 100644 --- a/packages/react-dom/src/client/ReactDOM.js +++ b/packages/react-dom/src/client/ReactDOM.js @@ -50,7 +50,12 @@ import {canUseDOM} from 'shared/ExecutionEnvironment'; import ReactVersion from 'shared/ReactVersion'; import {enableNewReconciler} from 'shared/ReactFeatureFlags'; -import {getClosestInstanceFromNode} from 'react-dom-bindings/src/client/ReactDOMComponentTree'; +import { + getClosestInstanceFromNode, + getInstanceFromNode, + getNodeFromInstance, + getFiberCurrentPropsFromNode, +} from 'react-dom-bindings/src/client/ReactDOMComponentTree'; import {restoreControlledState} from 'react-dom-bindings/src/client/ReactDOMComponent'; import { setAttemptSynchronousHydration, @@ -61,7 +66,11 @@ import { setAttemptHydrationAtPriority, } from 'react-dom-bindings/src/events/ReactDOMEventReplaying'; import {setBatchingImplementation} from 'react-dom-bindings/src/events/ReactDOMUpdateBatching'; -import {setRestoreImplementation} from 'react-dom-bindings/src/events/ReactDOMControlledComponent'; +import { + setRestoreImplementation, + enqueueStateRestore, + restoreStateIfNeeded, +} from 'react-dom-bindings/src/events/ReactDOMControlledComponent'; import Internals from '../ReactDOMSharedInternals'; setAttemptSynchronousHydration(attemptSynchronousHydration); @@ -198,6 +207,17 @@ export { runWithPriority as unstable_runWithPriority, }; +// Keep in sync with ReactTestUtils.js. +// This is an array for better minification. +Internals.Events = [ + getInstanceFromNode, + getNodeFromInstance, + getFiberCurrentPropsFromNode, + enqueueStateRestore, + restoreStateIfNeeded, + batchedUpdates, +]; + const foundDevTools = injectIntoDevTools({ findFiberByHostInstance: getClosestInstanceFromNode, bundleType: __DEV__ ? 1 : 0, diff --git a/packages/react-dom/src/client/ReactDOMRoot.js b/packages/react-dom/src/client/ReactDOMRoot.js index 399e8153c24b9..992634612857e 100644 --- a/packages/react-dom/src/client/ReactDOMRoot.js +++ b/packages/react-dom/src/client/ReactDOMRoot.js @@ -14,6 +14,7 @@ import type { } from 'react-reconciler/src/ReactInternalTypes'; import ReactDOMSharedInternals from '../ReactDOMSharedInternals'; +const {Dispatcher} = ReactDOMSharedInternals; import {ReactDOMClientDispatcher} from 'react-dom-bindings/src/client/ReactDOMFloatClient'; import {queueExplicitHydrationTarget} from 'react-dom-bindings/src/events/ReactDOMEventReplaying'; import {REACT_ELEMENT_TYPE} from 'shared/ReactSymbols'; @@ -237,17 +238,16 @@ export function createRoot( ); markContainerAsRoot(root.current, container); + if (enableFloat) { + // Set the default dispatcher to the client dispatcher + Dispatcher.current = ReactDOMClientDispatcher; + } const rootContainerElement: Document | Element | DocumentFragment = container.nodeType === COMMENT_NODE ? (container.parentNode: any) : container; listenToAllSupportedEvents(rootContainerElement); - if (enableFloat) { - // Set the default dispatcher to the client dispatcher - ReactDOMSharedInternals.Dispatcher.current = ReactDOMClientDispatcher; - } - return new ReactDOMRoot(root); } @@ -326,6 +326,10 @@ export function hydrateRoot( transitionCallbacks, ); markContainerAsRoot(root.current, container); + if (enableFloat) { + // Set the default dispatcher to the client dispatcher + Dispatcher.current = ReactDOMClientDispatcher; + } // This can't be a comment node since hydration doesn't work on comment nodes anyway. listenToAllSupportedEvents(container); @@ -336,11 +340,6 @@ export function hydrateRoot( } } - if (enableFloat) { - // Set the default dispatcher to the client dispatcher - ReactDOMSharedInternals.Dispatcher.current = ReactDOMClientDispatcher; - } - return new ReactDOMHydrationRoot(root); } diff --git a/packages/react-server-dom-relay/package.json b/packages/react-server-dom-relay/package.json index 793363bb30ddc..5fef49b1d1b75 100644 --- a/packages/react-server-dom-relay/package.json +++ b/packages/react-server-dom-relay/package.json @@ -11,6 +11,7 @@ "scheduler": "^0.11.0" }, "peerDependencies": { - "react": "^17.0.0" + "react": "^17.0.0", + "react-dom": "^17.0.0" } } From e2bc267298b9340bf67984c858ef58d5a4d4b8fc Mon Sep 17 00:00:00 2001 From: Josh Story Date: Thu, 29 Sep 2022 14:13:38 -0700 Subject: [PATCH 15/35] wip fixes --- .../react-dom-bindings/src/client/ReactDOMFloatClient.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js b/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js index 05bb317c93a29..5755212f1a048 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js +++ b/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js @@ -103,13 +103,13 @@ let previousDispatcher = null; export function prepareToRenderResources(ownerDocument: Document) { currentDocument = lastCurrentDocument = ownerDocument; stylesRestorable = true; - previousDispatcher = ReactDOMSharedInternals.Dispatcher.current; - ReactDOMSharedInternals.Dispatcher.current = ReactDOMClientDispatcher; + previousDispatcher = Dispatcher.current; + Dispatcher.current = ReactDOMClientDispatcher; } export function cleanupAfterRenderResources() { currentDocument = null; - ReactDOMSharedInternals.Dispatcher.current = previousDispatcher; + Dispatcher.current = previousDispatcher; previousDispatcher = null; } From 0af21ba9c7d6f32db9b8c4021d6bdce8c5302906 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Thu, 29 Sep 2022 14:41:22 -0700 Subject: [PATCH 16/35] query for existing styles in acquisition instead of construction. remove restoreAllStyles --- .../src/client/ReactDOMFloatClient.js | 16 +--------------- .../src/client/ReactDOMHostConfig.js | 16 ---------------- 2 files changed, 1 insertion(+), 31 deletions(-) diff --git a/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js b/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js index 5755212f1a048..e63def25c1f81 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js +++ b/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js @@ -564,7 +564,7 @@ function immediatelyPreloadStyleResource(resource: StyleResource) { // don't always want to preload a style, in particular when we are going to synchronously insert // that style. We confirm the style resource has no preload already and then construct it. If // we wait and call this later it is possible a preload will already exist for this href - if (resource.instance === null && resource.hint === null) { + if (resource.loaded === false && resource.hint === null) { const {href, props} = resource; const preloadProps = preloadPropsFromStyleProps(props); resource.hint = createPreloadResource( @@ -754,20 +754,6 @@ function insertStyleInstance( } } -export function restoreAllStylesResources() { - if (stylesRestorable) { - stylesRestorable = false; - const iter = styleResources.values(); - let resource; - while ((resource = iter.next().value)) { - const {instance, count, ownerDocument, precedence} = resource; - if (count && instance && !ownerDocument.contains(instance)) { - insertStyleInstance(instance, precedence, ownerDocument); - } - } - } -} - function insertPreloadInstance( instance: Instance, ownerDocument: Document, diff --git a/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js b/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js index e73bb9306ea90..38be54d68a446 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js @@ -83,7 +83,6 @@ import { prepareToRenderResources, cleanupAfterRenderResources, isHostResourceType, - restoreAllStylesResources, } from './ReactDOMFloatClient'; export type Type = string; @@ -312,14 +311,6 @@ export function finalizeInitialChildren( return !!props.autoFocus; case 'img': return true; - case 'html': - case 'head': - case 'body': { - if (enableFloat) { - return true; - } - } - // eslint-disable-next-line-no-fallthrough default: return false; } @@ -458,13 +449,6 @@ export function commitMount( } return; } - case 'html': - case 'head': - case 'body': { - if (false && enableFloat) { - restoreAllStylesResources(); - } - } } } From 8da096cc9467e3c767dda5fd797a84764ec79b24 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Thu, 29 Sep 2022 14:57:58 -0700 Subject: [PATCH 17/35] revert to request based preamble --- .../src/server/ReactDOMServerFormatConfig.js | 210 +++++++++--------- .../ReactDOMServerLegacyFormatConfig.js | 1 - .../server/ReactNativeServerFormatConfig.js | 3 +- .../src/ReactNoopServer.js | 1 + packages/react-server/src/ReactFizzServer.js | 67 +----- .../forks/ReactServerFormatConfig.custom.js | 1 - 6 files changed, 121 insertions(+), 162 deletions(-) diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js index d553c957e4b2b..e532a399ce1e8 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js @@ -7,7 +7,6 @@ * @flow */ -import type {ArrayWithPreamble} from 'react-server/src/ReactFizzServer'; import type {ReactNodeList} from 'shared/ReactTypes'; import type {Resources, BoundaryResources} from './ReactDOMFloatServer'; export type {Resources, BoundaryResources}; @@ -94,8 +93,6 @@ export type ResponseState = { ... }; -export const emptyChunk = stringToPrecomputedChunk(''); - const startInlineScript = stringToPrecomputedChunk(''); @@ -1300,33 +1297,36 @@ function pushStartTitle( } function pushStartHead( - target: ArrayWithPreamble, + target: Array, + preamble: Array, props: Object, tag: string, responseState: ResponseState, - formatContext: FormatContext, ): ReactNodeList { - const children = pushStartGenericElement(target, props, tag, responseState); - target._preambleIndex = target.length; - return children; + return pushStartGenericElement( + enableFloat ? preamble : target, + props, + tag, + responseState, + ); } function pushStartHtml( - target: ArrayWithPreamble, + target: Array, + preamble: Array, props: Object, tag: string, responseState: ResponseState, formatContext: FormatContext, ): ReactNodeList { + target = enableFloat ? preamble : target; if (formatContext.insertionMode === ROOT_HTML_MODE) { // If we're rendering the html tag and we're at the root (i.e. not in foreignObject) // then we also emit the DOCTYPE as part of the root content as a convenience for // rendering the whole document. target.push(DOCTYPE); } - const children = pushStartGenericElement(target, props, tag, responseState); - target._preambleIndex = target.length; - return children; + return pushStartGenericElement(target, props, tag, responseState); } function pushStartGenericElement( @@ -1544,7 +1544,8 @@ function startChunkForTag(tag: string): PrecomputedChunk { const DOCTYPE: PrecomputedChunk = stringToPrecomputedChunk(''); export function pushStartInstance( - target: ArrayWithPreamble, + target: Array, + preamble: Array, type: string, props: Object, responseState: ResponseState, @@ -1639,9 +1640,16 @@ export function pushStartInstance( } // Preamble start tags case 'head': - return pushStartHead(target, props, type, responseState, formatContext); + return pushStartHead(target, preamble, props, type, responseState); case 'html': { - return pushStartHtml(target, props, type, responseState, formatContext); + return pushStartHtml( + target, + preamble, + props, + type, + responseState, + formatContext, + ); } default: { if (type.indexOf('-') === -1 && typeof props.is !== 'string') { @@ -2223,96 +2231,96 @@ const completeSegmentFunction = // 'function $RC(a,b){a=document.getElementById(a);b=document.getElementById(b);b.parentNode.removeChild(b);if(a){a=a.previousSibling;var f=a.parentNode,c=a.nextSibling,e=0;do{if(c&&8===c.nodeType){var d=c.data;if("/$"===d)if(0===e)break;else e--;else"$"!==d&&"$?"!==d&&"$!"!==d||e++}d=c.nextSibling;f.removeChild(c);c=d}while(c);for(;b.firstChild;)f.insertBefore(b.firstChild,c);a.data="$";a._reactRetry&&a._reactRetry()}};'; // const completeBoundaryFunction = // '$RC=function(){function h(e,f){var a=document.getElementById(e),c=document.getElementById(f);c.parentNode.removeChild(c);if(a){a=a.previousSibling;var k=a.parentNode,b=a.nextSibling,g=0;do{if(b&&8===b.nodeType){var d=b.data;if("/$"===d)if(0===g)break;else g--;else"$"!==d&&"$?"!==d&&"$!"!==d||g++}d=b.nextSibling;k.removeChild(b);b=d}while(b);for(;c.firstChild;)k.insertBefore(c.firstChild,b);a.data="$";a._reactRetry&&a._reactRetry()}}return function(e,f,a){if(a&&(a=$RR(a)))return Promise.all(a).then(h.bind(null,e,f))["catch"](function(c){return console.log("caught",c)});h(e,f)}}()' -const styleInsertionFunction = - '$RM=new Map;function $RR(l,m,t){function r(n){this.s=n}for(var p=new Map,q=document,g,f,e=q.querySelectorAll("link[data-rprec]"),d=0;f=e[d++];)p.set(f.dataset.rprec,g=f);f=0;e=[];for(var c,h,b,a;c=t[f++];){var k=0;h=c[k++];if(b=$RM.get(h))"l"!==b.s&&e.push(b);else{a=q.createElement("link");a.href=h;a.rel="stylesheet";for(a.dataset.rprec=d=c[k++];b=c[k++];)a.setAttribute(b,c[k++]);b=a._p=new Promise(function(n,u){a.onload=n;a.onerror=u});b.then(r.bind(b,"l"),r.bind(b,"e"));$RM.set(h,b);e.push(b);c=p.get(d)||g;c===g&&(g=a);p.set(d,a);c?c.parentNode.insertBefore(a,c.nextSibling):(d=q.head,d.insertBefore(a,d.firstChild))}}e.length?Promise.all(e).then($RC.bind(null,l,m,null),$RC.bind(null,l,m,"Resource failed to load")):$RC(l,m)};'; -// const styleInsertionFunction = `$RM = new Map(); -// function $RR(suspenseBoundaryID, contentID, styles) { -// const precedences = new Map(); -// const thisDocument = document; -// let lastResource, node; - -// // Seed the precedence list with existing resources -// let nodes = thisDocument.querySelectorAll('link[data-rprec]'); -// for (let i = 0;node = nodes[i++];) { -// precedences.set(node.dataset.rprec, lastResource = node); -// } - -// let i = 0; -// let dependencies = []; -// let style, href, precedence, attr, loadingState, resourceEl; - -// function setStatus(s) { -// this.s = s; -// } - -// while (style = styles[i++]) { -// let j = 0; -// href = style[j++]; -// // We check if this resource is already in our resourceMap and reuse it if so. -// // If it is already loaded we don't return it as a depenendency since there is nothing -// // to wait for -// loadingState = $RM.get(href); -// if (loadingState) { -// if (loadingState.s !== 'l') { -// dependencies.push(loadingState); -// } -// continue; -// } +// const styleInsertionFunction = +// '$RM=new Map;function $RR(l,m,t){function r(n){this.s=n}for(var p=new Map,q=document,g,f,e=q.querySelectorAll("link[data-rprec]"),d=0;f=e[d++];)p.set(f.dataset.rprec,g=f);f=0;e=[];for(var c,h,b,a;c=t[f++];){var k=0;h=c[k++];if(b=$RM.get(h))"l"!==b.s&&e.push(b);else{a=q.createElement("link");a.href=h;a.rel="stylesheet";for(a.dataset.rprec=d=c[k++];b=c[k++];)a.setAttribute(b,c[k++]);b=a._p=new Promise(function(n,u){a.onload=n;a.onerror=u});b.then(r.bind(b,"l"),r.bind(b,"e"));$RM.set(h,b);e.push(b);c=p.get(d)||g;c===g&&(g=a);p.set(d,a);c?c.parentNode.insertBefore(a,c.nextSibling):(d=q.head,d.insertBefore(a,d.firstChild))}}e.length?Promise.all(e).then($RC.bind(null,l,m,null),$RC.bind(null,l,m,"Resource failed to load")):$RC(l,m)};'; +const styleInsertionFunction = `$RM = new Map(); +function $RR(suspenseBoundaryID, contentID, styles) { + const precedences = new Map(); + const thisDocument = document; + let lastResource, node; -// // We construct our new resource element, looping over remaining attributes if any -// // setting them to the Element. -// resourceEl = thisDocument.createElement("link"); -// resourceEl.href = href; -// resourceEl.rel = 'stylesheet'; -// resourceEl.dataset.rprec = precedence = style[j++]; -// while(attr = style[j++]) { -// resourceEl.setAttribute(attr, style[j++]); -// } + // Seed the precedence list with existing resources + let nodes = thisDocument.querySelectorAll('link[data-rprec]'); + for (let i = 0;node = nodes[i++];) { + precedences.set(node.dataset.rprec, lastResource = node); + } -// // We stash a pending promise in our map by href which will resolve or reject -// // when the underlying resource loads or errors. We add it to the dependencies -// // array to be returned. -// loadingState = resourceEl._p = new Promise((re, rj) => { -// resourceEl.onload = re; -// resourceEl.onerror = rj; -// }) -// loadingState.then( -// setStatus.bind(loadingState, 'l'), -// setStatus.bind(loadingState, 'e') -// ); -// $RM.set(href, loadingState); -// dependencies.push(loadingState); - -// // The prior style resource is the last one placed at a given -// // precedence or the last resource itself which may be null. -// // We grab this value and then update the last resource for this -// // precedence to be the inserted element, updating the lastResource -// // pointer if needed. -// let prior = precedences.get(precedence) || lastResource; -// if (prior === lastResource) { -// lastResource = resourceEl -// } -// precedences.set(precedence, resourceEl) + let i = 0; + let dependencies = []; + let style, href, precedence, attr, loadingState, resourceEl; + + function setStatus(s) { + this.s = s; + } + + while (style = styles[i++]) { + let j = 0; + href = style[j++]; + // We check if this resource is already in our resourceMap and reuse it if so. + // If it is already loaded we don't return it as a depenendency since there is nothing + // to wait for + loadingState = $RM.get(href); + if (loadingState) { + if (loadingState.s !== 'l') { + dependencies.push(loadingState); + } + continue; + } -// // Finally, we insert the newly constructed instance at an appropriate location -// // in the Document. -// if (prior) { -// prior.parentNode.insertBefore(resourceEl, prior.nextSibling); -// } else { -// let head = thisDocument.head; -// head.insertBefore(resourceEl, head.firstChild); -// } -// } + // We construct our new resource element, looping over remaining attributes if any + // setting them to the Element. + resourceEl = thisDocument.createElement("link"); + resourceEl.href = href; + resourceEl.rel = 'stylesheet'; + resourceEl.dataset.rprec = precedence = style[j++]; + while(attr = style[j++]) { + resourceEl.setAttribute(attr, style[j++]); + } + + // We stash a pending promise in our map by href which will resolve or reject + // when the underlying resource loads or errors. We add it to the dependencies + // array to be returned. + loadingState = resourceEl._p = new Promise((re, rj) => { + resourceEl.onload = re; + resourceEl.onerror = rj; + }) + loadingState.then( + setStatus.bind(loadingState, 'l'), + setStatus.bind(loadingState, 'e') + ); + $RM.set(href, loadingState); + dependencies.push(loadingState); + + // The prior style resource is the last one placed at a given + // precedence or the last resource itself which may be null. + // We grab this value and then update the last resource for this + // precedence to be the inserted element, updating the lastResource + // pointer if needed. + let prior = precedences.get(precedence) || lastResource; + if (prior === lastResource) { + lastResource = resourceEl + } + precedences.set(precedence, resourceEl) + + // Finally, we insert the newly constructed instance at an appropriate location + // in the Document. + if (prior) { + prior.parentNode.insertBefore(resourceEl, prior.nextSibling); + } else { + let head = thisDocument.head; + head.insertBefore(resourceEl, head.firstChild); + } + } -// if (dependencies.length) { -// Promise.all(dependencies).then( -// $RC.bind(null, suspenseBoundaryID, contentID, null), -// $RC.bind(null, suspenseBoundaryID, contentID, "Resource failed to load") -// ); -// } else { -// $RC(suspenseBoundaryID, contentID); -// } -// }`; + if (dependencies.length) { + Promise.all(dependencies).then( + $RC.bind(null, suspenseBoundaryID, contentID, ''), + $RC.bind(null, suspenseBoundaryID, contentID, "Resource failed to load") + ); + } else { + $RC(suspenseBoundaryID, contentID); + } +}`; // const completeBoundaryFunction = // '$RC=function(){function f(a,c,d){var b=document.getElementById(a);if(b){a=b.previousSibling;if(c){d=a.parentNode;b=a.nextSibling;var g=0;do{if(b&&8===b.nodeType){var e=b.data;if("/$"===e)if(0===g)break;else g--;else"$"!==e&&"$?"!==e&&"$!"!==e||g++}e=b.nextSibling;d.removeChild(b);b=e}while(b);for(;c.firstChild;)d.insertBefore(c.firstChild,b);a.data="$"}else a.data="$!",b.setAttribute("data-dgst",d);a._reactRetry&&a._reactRetry()}}return function(a,c,d){c=document.getElementById(c);c.parentNode.removeChild(c);if(d&&(d=$RR(d)))return Promise.all(d).then(f.bind(null,a,c,null),f.bind(null,a,null,"Resource failed to load"));f(a,c)}}();'; const completeBoundaryFunction = `function $RC(suspenseBoundaryID, contentID, errorDigest) { diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js b/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js index 7c04ac48442c7..c3928bab646f8 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js @@ -110,7 +110,6 @@ export { setCurrentlyRenderingBoundaryResourcesTarget, prepareToRender, cleanupAfterRender, - emptyChunk, } from './ReactDOMServerFormatConfig'; import {stringToChunk} from 'react-server/src/ReactServerStreamConfig'; diff --git a/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js b/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js index e0924388ba0ae..cc0ecde062b3d 100644 --- a/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js +++ b/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js @@ -65,8 +65,6 @@ export type ResponseState = { nextSuspenseID: number, }; -export const emptyChunk = stringToPrecomputedChunk(''); - // Allows us to keep track of what we've already written so we can refer back to it. export function createResponseState(): ResponseState { return { @@ -142,6 +140,7 @@ export function pushTextInstance( export function pushStartInstance( target: Array, + preamble: Array, type: string, props: Object, responseState: ResponseState, diff --git a/packages/react-noop-renderer/src/ReactNoopServer.js b/packages/react-noop-renderer/src/ReactNoopServer.js index 662668b2fddfa..8afadf54d0a3e 100644 --- a/packages/react-noop-renderer/src/ReactNoopServer.js +++ b/packages/react-noop-renderer/src/ReactNoopServer.js @@ -116,6 +116,7 @@ const ReactNoopServer = ReactFizzServer({ }, pushStartInstance( target: Array, + preamble: Array, type: string, props: Object, ): ReactNodeList { diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 13af610f32f22..a0e76c28bf044 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -74,7 +74,6 @@ import { setCurrentlyRenderingBoundaryResourcesTarget, createResources, createBoundaryResources, - emptyChunk, } from './ReactServerFormatConfig'; import { constructClassInstance, @@ -183,18 +182,12 @@ const ERRORED = 4; type Root = null; -export type ArrayWithPreamble = Array & {_preambleIndex?: number}; - -function asArrayWithPreamble(a: Array): ArrayWithPreamble { - return (a: any); -} - type Segment = { status: 0 | 1 | 2 | 3 | 4, parentFlushed: boolean, // typically a segment will be flushed by its parent, except if its parent was already flushed id: number, // starts as 0 and is lazily assigned if the parent flushes early +index: number, // the index within the parent's chunks or 0 at the root - +chunks: ArrayWithPreamble, + +chunks: Array, +children: Array, // The context that this segment was created in. formatContext: FormatContext, @@ -227,6 +220,7 @@ export opaque type Request = { clientRenderedBoundaries: Array, // Errored or client rendered but not yet flushed. completedBoundaries: Array, // Completed but not yet fully flushed boundaries to show. partialBoundaries: Array, // Partially completed boundaries that can flush its segments early. + +preamble: Array, // Chunks that need to be emitted before any segment chunks. +postamble: Array, // Chunks that need to be emitted after segments, waiting for all pending root tasks to finish // onError is called when an error happens anywhere in the tree. It might recover. // The return string is used in production primarily to avoid leaking internals, secondarily to save bytes. @@ -303,6 +297,7 @@ export function createRequest( clientRenderedBoundaries: [], completedBoundaries: [], partialBoundaries: [], + preamble: [], postamble: [], onError: onError === undefined ? defaultErrorHandler : onError, onAllReady: onAllReady === undefined ? noop : onAllReady, @@ -411,7 +406,7 @@ function createPendingSegment( id: -1, // lazily assigned later index, parentFlushed: false, - chunks: asArrayWithPreamble([]), + chunks: [], children: [], formatContext, boundary, @@ -700,6 +695,7 @@ function renderHostElement( const children = pushStartInstance( segment.chunks, + request.preamble, type, props, request.responseState, @@ -1949,53 +1945,6 @@ export function performWork(request: Request): void { } } -function flushPreamble( - request: Request, - destination: Destination, - segment: Segment, -): boolean { - const boundary = segment.boundary; - if (boundary) { - // Preamble needs to be part of the shell - return true; - } - if (segment.status === COMPLETED) { - const chunks = segment.chunks; - const preambleIndex = chunks._preambleIndex || 0; - let chunkIdx = 0; - const children = segment.children; - - for (let childIdx = 0; childIdx < children.length; childIdx++) { - const nextChild = children[childIdx]; - // Write all the chunks up until the next child. - for (; chunkIdx < nextChild.index; chunkIdx++) { - if (chunkIdx < preambleIndex) { - writeChunk(destination, chunks[chunkIdx]); - chunks[chunkIdx] = emptyChunk; - } else { - // We have encountered a chunk that isn't preamble and must halt - return true; - } - } - if (flushPreamble(request, destination, nextChild)) { - // this recursive call halted, propagate - return true; - } - } - for (; chunkIdx < chunks.length; chunkIdx++) { - if (chunkIdx < preambleIndex) { - writeChunk(destination, chunks[chunkIdx]); - chunks[chunkIdx] = emptyChunk; - } else { - // We have encountered a chunk that isn't preamble and must halt - return true; - } - } - return false; - } - return true; -} - function flushSubtree( request: Request, destination: Destination, @@ -2297,7 +2246,11 @@ function flushCompletedQueues( if (completedRootSegment !== null) { if (request.pendingRootTasks === 0) { if (enableFloat) { - flushPreamble(request, destination, completedRootSegment); + const preamble = request.preamble; + for (i = 0; i < preamble.length; i++) { + // we expect the preamble to be tiny and will ignore backpressure + writeChunk(destination, preamble[i]); + } flushInitialResources( destination, diff --git a/packages/react-server/src/forks/ReactServerFormatConfig.custom.js b/packages/react-server/src/forks/ReactServerFormatConfig.custom.js index 08a2b45ec9424..3fb9e30d543e3 100644 --- a/packages/react-server/src/forks/ReactServerFormatConfig.custom.js +++ b/packages/react-server/src/forks/ReactServerFormatConfig.custom.js @@ -68,7 +68,6 @@ export const writeCompletedBoundaryInstruction = $$$hostConfig.writeCompletedBoundaryInstruction; export const writeClientRenderBoundaryInstruction = $$$hostConfig.writeClientRenderBoundaryInstruction; -export const emptyChunk = $$$hostConfig.emptyChunk; // ------------------------- // Resources From 02984d7be5fc58e31154a92567559054fd6980e5 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Thu, 29 Sep 2022 15:44:49 -0700 Subject: [PATCH 18/35] cleanup style insertion scripts --- .../src/server/ReactDOMServerFormatConfig.js | 498 ++++++------------ 1 file changed, 161 insertions(+), 337 deletions(-) diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js index e532a399ce1e8..c438595882a11 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js @@ -2018,7 +2018,9 @@ export function writeEndSegment( // const SUSPENSE_END_DATA = '/$'; // const SUSPENSE_PENDING_START_DATA = '$?'; // const SUSPENSE_FALLBACK_START_DATA = '$!'; -// +// const LOADED = 'l'; +// const ERRORED = 'e'; + // function clientRenderBoundary(suspenseBoundaryID, errorDigest, errorMsg, errorComponentStack) { // // Find the fallback's first element. // const suspenseIdNode = document.getElementById(suspenseBoundaryID); @@ -2041,15 +2043,12 @@ export function writeEndSegment( // suspenseNode._reactRetry(); // } // } -// -// const insertStyles = (function() { -// const resourceMap = new Map(); + +// resourceMap = new Map(); +// function completeBoundaryWithStyles(suspenseBoundaryID, contentID, styles) { // const precedences = new Map(); -// // We omit rel stylesheet because only stylesheets should have data-prec attribute -// // and we can concievably use another kind of link to act as a placeholder for a -// // precedence that does not yet have any resources. +// const thisDocument = document; // let lastResource, node; -// let thisDocument = document; // // Seed the precedence list with existing resources // let nodes = thisDocument.querySelectorAll('link[data-rprec]'); @@ -2057,158 +2056,150 @@ export function writeEndSegment( // precedences.set(node.dataset.rprec, lastResource = node); // } -// function getPromise(resourceEl) { -// let p = resourceEl._p = new Promise((re, rj) => { -// resourceEl.onload = re; -// resourceEl.onerror = rj; -// }).then(() => { -// p.s = 'l'; -// }, (e) => { -// p.s = 'e'; -// throw e; -// }); -// return p; +// let i = 0; +// let dependencies = []; +// let style, href, precedence, attr, loadingState, resourceEl; + +// function setStatus(s) { +// this.s = s; // } -// return function insertStyle(styles) { -// let i = 0; -// let dependencies, style, href, precedence, attr, loadingState, resourceEl; - -// while (style = styles[i++]) { -// let j = 0; -// href = style[j++]; -// // We check if this resource is already in our resourceMap and reuse it if so. -// // If it is already loaded we don't return it as a depenendency since there is nothing -// // to wait for -// loadingState = resourceMap.get(href); -// if (loadingState) { -// if (loadingState.s !== 'l') { -// dependencies ? dependencies.push(loadingState) : dependencies = [loadingState]; -// } -// continue; +// while (style = styles[i++]) { +// let j = 0; +// href = style[j++]; +// // We check if this resource is already in our resourceMap and reuse it if so. +// // If it is already loaded we don't return it as a depenendency since there is nothing +// // to wait for +// loadingState = resourceMap.get(href); +// if (loadingState) { +// if (loadingState.s !== 'l') { +// dependencies.push(loadingState); // } +// continue; +// } -// // We construct our new resource element, looping over remaining attributes if any -// // setting them to the Element. -// resourceEl = thisDocument.createElement("link"); -// resourceEl.href = href; -// resourceEl.rel = 'stylesheet'; -// resourceEl.dataset.rprec = precedence = style[j++]; -// while(attr = style[j++]) { -// resourceEl.setAttribute(attr, style[j++]); -// } +// // We construct our new resource element, looping over remaining attributes if any +// // setting them to the Element. +// resourceEl = thisDocument.createElement("link"); +// resourceEl.href = href; +// resourceEl.rel = 'stylesheet'; +// resourceEl.dataset.rprec = precedence = style[j++]; +// while(attr = style[j++]) { +// resourceEl.setAttribute(attr, style[j++]); +// } -// // We stash a pending promise in our map by href which will resolve or reject -// // when the underlying resource loads or errors. We add it to the dependencies -// // array to be returned. -// loadingState = getPromise(resourceEl); -// resourceMap.set(href, loadingState); -// dependencies ? dependencies.push(loadingState) : dependencies = [loadingState]; - -// // The prior style resource is the last one placed at a given -// // precedence or the last resource itself which may be null. -// // We grab this value and then update the last resource for this -// // precedence to be the inserted element, updating the lastResource -// // pointer if needed. -// let prior = precedences.get(precedence) || lastResource; -// if (prior === lastResource) { -// lastResource = resourceEl -// } -// precedences.set(precedence, resourceEl) - -// // Finally, we insert the newly constructed instance at an appropriate location -// // in the Document. -// if (prior) { -// prior.parentNode.insertBefore(resourceEl, prior.nextSibling); -// } else { -// let head = thisDocument.head; -// head.insertBefore(resourceEl, head.firstChild); -// } +// // We stash a pending promise in our map by href which will resolve or reject +// // when the underlying resource loads or errors. We add it to the dependencies +// // array to be returned. +// loadingState = resourceEl._p = new Promise((re, rj) => { +// resourceEl.onload = re; +// resourceEl.onerror = rj; +// }) +// loadingState.then( +// setStatus.bind(loadingState, LOADED), +// setStatus.bind(loadingState, ERRORED) +// ); +// resourceMap.set(href, loadingState); +// dependencies.push(loadingState); + +// // The prior style resource is the last one placed at a given +// // precedence or the last resource itself which may be null. +// // We grab this value and then update the last resource for this +// // precedence to be the inserted element, updating the lastResource +// // pointer if needed. +// let prior = precedences.get(precedence) || lastResource; +// if (prior === lastResource) { +// lastResource = resourceEl // } -// return dependencies; -// } -// })() -// -// const completeBoundary = (function() { -// function flipBoundary(suspenseBoundaryID, contentNode, errorDigest) { -// // Find the fallback's first element. -// const suspenseIdNode = document.getElementById(suspenseBoundaryID); -// if (!suspenseIdNode) { -// // The user must have already navigated away from this tree. -// // E.g. because the parent was hydrated. That's fine there's nothing to do -// // but we have to make sure that we already deleted the container node. -// return; +// precedences.set(precedence, resourceEl) + +// // Finally, we insert the newly constructed instance at an appropriate location +// // in the Document. +// if (prior) { +// prior.parentNode.insertBefore(resourceEl, prior.nextSibling); +// } else { +// let head = thisDocument.head; +// head.insertBefore(resourceEl, head.firstChild); // } -// // Find the boundary around the fallback. This is always the previous node. -// const suspenseNode = suspenseIdNode.previousSibling; - -// if (contentNode) { -// // Clear all the existing children. This is complicated because -// // there can be embedded Suspense boundaries in the fallback. -// // This is similar to clearSuspenseBoundary in ReactDOMHostConfig. -// // TODO: We could avoid this if we never emitted suspense boundaries in fallback trees. -// // They never hydrate anyway. However, currently we support incrementally loading the fallback. -// const parentInstance = suspenseNode.parentNode; -// let node = suspenseNode.nextSibling; -// let depth = 0; -// do { -// if (node && node.nodeType === COMMENT_NODE) { -// const data = node.data; -// if (data === SUSPENSE_END_DATA) { -// if (depth === 0) { -// break; -// } else { -// depth--; -// } -// } else if ( -// data === SUSPENSE_START_DATA || -// data === SUSPENSE_PENDING_START_DATA || -// data === SUSPENSE_FALLBACK_START_DATA -// ) { -// depth++; +// } + +// if (dependencies.length) { +// Promise.all(dependencies).then( +// completeBoundary.bind(null, suspenseBoundaryID, contentID, ''), +// completeBoundary.bind(null, suspenseBoundaryID, contentID, "Resource failed to load") +// ); +// } else { +// completeBoundary(suspenseBoundaryID, contentID); +// } +// } + +// function completeBoundary(suspenseBoundaryID, contentID, errorDigest) { +// const contentNode = document.getElementById(contentID); +// // We'll detach the content node so that regardless of what happens next we don't leave in the tree. +// // This might also help by not causing recalcing each time we move a child from here to the target. +// contentNode.parentNode.removeChild(contentNode); + +// // Find the fallback's first element. +// const suspenseIdNode = document.getElementById(suspenseBoundaryID); +// if (!suspenseIdNode) { +// // The user must have already navigated away from this tree. +// // E.g. because the parent was hydrated. That's fine there's nothing to do +// // but we have to make sure that we already deleted the container node. +// return; +// } +// // Find the boundary around the fallback. This is always the previous node. +// const suspenseNode = suspenseIdNode.previousSibling; + +// if (!errorDigest) { +// // Clear all the existing children. This is complicated because +// // there can be embedded Suspense boundaries in the fallback. +// // This is similar to clearSuspenseBoundary in ReactDOMHostConfig. +// // TODO: We could avoid this if we never emitted suspense boundaries in fallback trees. +// // They never hydrate anyway. However, currently we support incrementally loading the fallback. +// const parentInstance = suspenseNode.parentNode; +// let node = suspenseNode.nextSibling; +// let depth = 0; +// do { +// if (node && node.nodeType === COMMENT_NODE) { +// const data = node.data; +// if (data === SUSPENSE_END_DATA) { +// if (depth === 0) { +// break; +// } else { +// depth--; // } +// } else if ( +// data === SUSPENSE_START_DATA || +// data === SUSPENSE_PENDING_START_DATA || +// data === SUSPENSE_FALLBACK_START_DATA +// ) { +// depth++; // } -// -// const nextNode = node.nextSibling; -// parentInstance.removeChild(node); -// node = nextNode; -// } while (node); -// -// const endOfBoundary = node; -// -// // Insert all the children from the contentNode between the start and end of suspense boundary. -// while (contentNode.firstChild) { -// parentInstance.insertBefore(contentNode.firstChild, endOfBoundary); // } -// -// suspenseNode.data = '$'; -// } else { -// suspenseNode.data = '$!'; -// suspenseIdNode.setAttribute('data-dgst', errorDigest) -// } -// -// if (suspenseNode._reactRetry) { -// suspenseNode._reactRetry(); + +// const nextNode = node.nextSibling; +// parentInstance.removeChild(node); +// node = nextNode; +// } while (node); + +// const endOfBoundary = node; + +// // Insert all the children from the contentNode between the start and end of suspense boundary. +// while (contentNode.firstChild) { +// parentInstance.insertBefore(contentNode.firstChild, endOfBoundary); // } + +// suspenseNode.data = SUSPENSE_START_DATA; +// } else { +// suspenseNode.data = SUSPENSE_FALLBACK_START_DATA; +// suspenseIdNode.setAttribute('data-dgst', errorDigest) // } -// return function completeBoundary(suspenseBoundaryID, contentID, styleResources){ -// const contentNode = document.getElementById(contentID); -// // We'll detach the content node so that regardless of what happens next we don't leave in the tree. -// // This might also help by not causing recalcing each time we move a child from here to the target. -// contentNode.parentNode.removeChild(contentNode); -// if (styleResources) { -// const p = $RR(styleResources); -// if (p) { -// return Promise.all(p).then( -// flipBoundary.bind(null, suspenseBoundaryID, contentNode, null), -// flipBoundary.bind(null, suspenseBoundaryID, null, "Resource failed to load"), -// ); -// } -// } -// flipBoundary(suspenseBoundaryID, contentNode); + +// if (suspenseNode._reactRetry) { +// suspenseNode._reactRetry(); // } -// })() -// +// } + // function completeSegment(containerID, placeholderID) { // const segmentContainer = document.getElementById(containerID); // const placeholderNode = document.getElementById(placeholderID); @@ -2227,168 +2218,10 @@ export function writeEndSegment( const completeSegmentFunction = 'function $RS(a,b){a=document.getElementById(a);b=document.getElementById(b);for(a.parentNode.removeChild(a);a.firstChild;)b.parentNode.insertBefore(a.firstChild,b);b.parentNode.removeChild(b)}'; -// const completeBoundaryFunction = -// 'function $RC(a,b){a=document.getElementById(a);b=document.getElementById(b);b.parentNode.removeChild(b);if(a){a=a.previousSibling;var f=a.parentNode,c=a.nextSibling,e=0;do{if(c&&8===c.nodeType){var d=c.data;if("/$"===d)if(0===e)break;else e--;else"$"!==d&&"$?"!==d&&"$!"!==d||e++}d=c.nextSibling;f.removeChild(c);c=d}while(c);for(;b.firstChild;)f.insertBefore(b.firstChild,c);a.data="$";a._reactRetry&&a._reactRetry()}};'; -// const completeBoundaryFunction = -// '$RC=function(){function h(e,f){var a=document.getElementById(e),c=document.getElementById(f);c.parentNode.removeChild(c);if(a){a=a.previousSibling;var k=a.parentNode,b=a.nextSibling,g=0;do{if(b&&8===b.nodeType){var d=b.data;if("/$"===d)if(0===g)break;else g--;else"$"!==d&&"$?"!==d&&"$!"!==d||g++}d=b.nextSibling;k.removeChild(b);b=d}while(b);for(;c.firstChild;)k.insertBefore(c.firstChild,b);a.data="$";a._reactRetry&&a._reactRetry()}}return function(e,f,a){if(a&&(a=$RR(a)))return Promise.all(a).then(h.bind(null,e,f))["catch"](function(c){return console.log("caught",c)});h(e,f)}}()' -// const styleInsertionFunction = -// '$RM=new Map;function $RR(l,m,t){function r(n){this.s=n}for(var p=new Map,q=document,g,f,e=q.querySelectorAll("link[data-rprec]"),d=0;f=e[d++];)p.set(f.dataset.rprec,g=f);f=0;e=[];for(var c,h,b,a;c=t[f++];){var k=0;h=c[k++];if(b=$RM.get(h))"l"!==b.s&&e.push(b);else{a=q.createElement("link");a.href=h;a.rel="stylesheet";for(a.dataset.rprec=d=c[k++];b=c[k++];)a.setAttribute(b,c[k++]);b=a._p=new Promise(function(n,u){a.onload=n;a.onerror=u});b.then(r.bind(b,"l"),r.bind(b,"e"));$RM.set(h,b);e.push(b);c=p.get(d)||g;c===g&&(g=a);p.set(d,a);c?c.parentNode.insertBefore(a,c.nextSibling):(d=q.head,d.insertBefore(a,d.firstChild))}}e.length?Promise.all(e).then($RC.bind(null,l,m,null),$RC.bind(null,l,m,"Resource failed to load")):$RC(l,m)};'; -const styleInsertionFunction = `$RM = new Map(); -function $RR(suspenseBoundaryID, contentID, styles) { - const precedences = new Map(); - const thisDocument = document; - let lastResource, node; - - // Seed the precedence list with existing resources - let nodes = thisDocument.querySelectorAll('link[data-rprec]'); - for (let i = 0;node = nodes[i++];) { - precedences.set(node.dataset.rprec, lastResource = node); - } - - let i = 0; - let dependencies = []; - let style, href, precedence, attr, loadingState, resourceEl; - - function setStatus(s) { - this.s = s; - } - - while (style = styles[i++]) { - let j = 0; - href = style[j++]; - // We check if this resource is already in our resourceMap and reuse it if so. - // If it is already loaded we don't return it as a depenendency since there is nothing - // to wait for - loadingState = $RM.get(href); - if (loadingState) { - if (loadingState.s !== 'l') { - dependencies.push(loadingState); - } - continue; - } - - // We construct our new resource element, looping over remaining attributes if any - // setting them to the Element. - resourceEl = thisDocument.createElement("link"); - resourceEl.href = href; - resourceEl.rel = 'stylesheet'; - resourceEl.dataset.rprec = precedence = style[j++]; - while(attr = style[j++]) { - resourceEl.setAttribute(attr, style[j++]); - } - - // We stash a pending promise in our map by href which will resolve or reject - // when the underlying resource loads or errors. We add it to the dependencies - // array to be returned. - loadingState = resourceEl._p = new Promise((re, rj) => { - resourceEl.onload = re; - resourceEl.onerror = rj; - }) - loadingState.then( - setStatus.bind(loadingState, 'l'), - setStatus.bind(loadingState, 'e') - ); - $RM.set(href, loadingState); - dependencies.push(loadingState); - - // The prior style resource is the last one placed at a given - // precedence or the last resource itself which may be null. - // We grab this value and then update the last resource for this - // precedence to be the inserted element, updating the lastResource - // pointer if needed. - let prior = precedences.get(precedence) || lastResource; - if (prior === lastResource) { - lastResource = resourceEl - } - precedences.set(precedence, resourceEl) - - // Finally, we insert the newly constructed instance at an appropriate location - // in the Document. - if (prior) { - prior.parentNode.insertBefore(resourceEl, prior.nextSibling); - } else { - let head = thisDocument.head; - head.insertBefore(resourceEl, head.firstChild); - } - } - - if (dependencies.length) { - Promise.all(dependencies).then( - $RC.bind(null, suspenseBoundaryID, contentID, ''), - $RC.bind(null, suspenseBoundaryID, contentID, "Resource failed to load") - ); - } else { - $RC(suspenseBoundaryID, contentID); - } -}`; -// const completeBoundaryFunction = -// '$RC=function(){function f(a,c,d){var b=document.getElementById(a);if(b){a=b.previousSibling;if(c){d=a.parentNode;b=a.nextSibling;var g=0;do{if(b&&8===b.nodeType){var e=b.data;if("/$"===e)if(0===g)break;else g--;else"$"!==e&&"$?"!==e&&"$!"!==e||g++}e=b.nextSibling;d.removeChild(b);b=e}while(b);for(;c.firstChild;)d.insertBefore(c.firstChild,b);a.data="$"}else a.data="$!",b.setAttribute("data-dgst",d);a._reactRetry&&a._reactRetry()}}return function(a,c,d){c=document.getElementById(c);c.parentNode.removeChild(c);if(d&&(d=$RR(d)))return Promise.all(d).then(f.bind(null,a,c,null),f.bind(null,a,null,"Resource failed to load"));f(a,c)}}();'; -const completeBoundaryFunction = `function $RC(suspenseBoundaryID, contentID, errorDigest) { - const contentNode = document.getElementById(contentID); - // We'll detach the content node so that regardless of what happens next we don't leave in the tree. - // This might also help by not causing recalcing each time we move a child from here to the target. - contentNode.parentNode.removeChild(contentNode); - - // Find the fallback's first element. - const suspenseIdNode = document.getElementById(suspenseBoundaryID); - if (!suspenseIdNode) { - // The user must have already navigated away from this tree. - // E.g. because the parent was hydrated. That's fine there's nothing to do - // but we have to make sure that we already deleted the container node. - return; - } - // Find the boundary around the fallback. This is always the previous node. - const suspenseNode = suspenseIdNode.previousSibling; - - if (!errorDigest) { - // Clear all the existing children. This is complicated because - // there can be embedded Suspense boundaries in the fallback. - // This is similar to clearSuspenseBoundary in ReactDOMHostConfig. - // TODO: We could avoid this if we never emitted suspense boundaries in fallback trees. - // They never hydrate anyway. However, currently we support incrementally loading the fallback. - const parentInstance = suspenseNode.parentNode; - let node = suspenseNode.nextSibling; - let depth = 0; - do { - if (node && node.nodeType === 8) { - const data = node.data; - if (data === '/$') { - if (depth === 0) { - break; - } else { - depth--; - } - } else if ( - data === '$' || - data === '$?' || - data === '$!' - ) { - depth++; - } - } - - const nextNode = node.nextSibling; - parentInstance.removeChild(node); - node = nextNode; - } while (node); - - const endOfBoundary = node; - - // Insert all the children from the contentNode between the start and end of suspense boundary. - while (contentNode.firstChild) { - parentInstance.insertBefore(contentNode.firstChild, endOfBoundary); - } - - suspenseNode.data = '$'; - } else { - suspenseNode.data = '$!'; - suspenseIdNode.setAttribute('data-dgst', errorDigest) - } - - if (suspenseNode._reactRetry) { - suspenseNode._reactRetry(); - } -}`; +const completeBoundaryFunction = + 'function $RC(b,c,d){c=document.getElementById(c);c.parentNode.removeChild(c);var a=document.getElementById(b);if(a){b=a.previousSibling;if(d)b.data="$!",a.setAttribute("data-dgst",d);else{d=b.parentNode;a=b.nextSibling;var e=0;do{if(a&&a.nodeType===8){var h=a.data;if(h==="/$")if(0===e)break;else e--;else h!=="$"&&h!=="$?"&&h!=="$!"||e++}h=a.nextSibling;d.removeChild(a);a=h}while(a);for(;c.firstChild;)d.insertBefore(c.firstChild,a);b.data="$"}b._reactRetry&&b._reactRetry()}}'; +const styleInsertionFunction = + '$RM=new Map;function $RR(b,c,d){function a(t){this.s=t}for(var e=new Map,h=document,p,n,m=h.querySelectorAll("link[data-rprec]"),l=0;n=m[l++];)e.set(n.dataset.rprec,p=n);n=0;m=[];for(var k,q,g,f;k=d[n++];){var r=0;q=k[r++];if(g=$RM.get(q))"l"!==g.s&&m.push(g);else{f=h.createElement("link");f.href=q;f.rel="stylesheet";for(f.dataset.rprec=l=k[r++];g=k[r++];)f.setAttribute(g,k[r++]);g=f._p=new Promise(function(t,u){f.onload=t;f.onerror=u});g.then(a.bind(g,"l"),a.bind(g,"e"));$RM.set(q,g);m.push(g);k=e.get(l)||p;k===p&&(p=f);e.set(l,f);k?k.parentNode.insertBefore(f,k.nextSibling):(l=h.head,l.insertBefore(f,l.firstChild))}}m.length?Promise.all(m).then($RC.bind(null,b,c,""),$RC.bind(null,b,c,"Resource failed to load")):$RC(b,c)}'; const clientRenderFunction = 'function $RX(b,c,d,e){var a=document.getElementById(b);a&&(b=a.previousSibling,b.data="$!",a=a.dataset,c&&(a.dgst=c),d&&(a.msg=d),e&&(a.stck=e),b._reactRetry&&b._reactRetry())}'; @@ -2422,20 +2255,22 @@ export function writeCompletedSegmentInstruction( return writeChunkAndReturn(destination, completeSegmentScript3); } -const completeBoundaryScript1FullWithStyleInsertion = stringToPrecomputedChunk( - styleInsertionFunction + ';' + completeBoundaryFunction + ';$RR("', -); const completeBoundaryScript1Full = stringToPrecomputedChunk( completeBoundaryFunction + ';$RC("', ); -const completeBoundaryWithStylesScript1PartialWithStyleInsertion = stringToPrecomputedChunk( +const completeBoundaryScript1Partial = stringToPrecomputedChunk('$RC("'); + +const completeBoundaryWithStylesScript1FullBoth = stringToPrecomputedChunk( + completeBoundaryFunction + ';' + styleInsertionFunction + ';$RR("', +); +const completeBoundaryWithStylesScript1FullPartial = stringToPrecomputedChunk( styleInsertionFunction + ';$RR("', ); -const completeBoundaryScript1Partial = stringToPrecomputedChunk('$RC("'); const completeBoundaryWithStylesScript1Partial = stringToPrecomputedChunk( '$RR("', ); const completeBoundaryScript2 = stringToPrecomputedChunk('","'); +const completeBoundaryScript2a = stringToPrecomputedChunk('",'); const completeBoundaryScript3 = stringToPrecomputedChunk('"'); const completeBoundaryScript4 = stringToPrecomputedChunk(')'); @@ -2451,33 +2286,22 @@ export function writeCompletedBoundaryInstruction( hasStyleDependencies = hasStyleResourceDependencies(boundaryResources); } writeChunk(destination, responseState.startInlineScript); - if (!responseState.sentCompleteBoundaryFunction) { - if (enableFloat) { + if (enableFloat && hasStyleResourceDependencies(boundaryResources)) { + if (!responseState.sentCompleteBoundaryFunction) { responseState.sentCompleteBoundaryFunction = true; - if (hasStyleDependencies && !responseState.sentStyleInsertionFunction) { - responseState.sentStyleInsertionFunction = true; - writeChunk(destination, completeBoundaryScript1FullWithStyleInsertion); - } else { - writeChunk(destination, completeBoundaryScript1Full); - } + responseState.sentStyleInsertionFunction = true; + writeChunk(destination, completeBoundaryWithStylesScript1FullBoth); + } else if (!responseState.sentStyleInsertionFunction) { + responseState.sentStyleInsertionFunction = true; + writeChunk(destination, completeBoundaryWithStylesScript1FullPartial); } else { - // The first time we write this, we'll need to include the full implementation. - responseState.sentCompleteBoundaryFunction = true; - writeChunk(destination, completeBoundaryScript1Full); + writeChunk(destination, completeBoundaryWithStylesScript1Partial); } } else { - if (enableFloat && hasStyleDependencies) { - if (!responseState.sentStyleInsertionFunction) { - responseState.sentStyleInsertionFunction = true; - writeChunk( - destination, - completeBoundaryWithStylesScript1PartialWithStyleInsertion, - ); - } else { - writeChunk(destination, completeBoundaryWithStylesScript1Partial); - } + if (!responseState.sentCompleteBoundaryFunction) { + responseState.sentCompleteBoundaryFunction = true; + writeChunk(destination, completeBoundaryScript1Full); } else { - // Future calls can just reuse the same function. writeChunk(destination, completeBoundaryScript1Partial); } } @@ -2494,7 +2318,7 @@ export function writeCompletedBoundaryInstruction( writeChunk(destination, responseState.segmentPrefix); writeChunk(destination, formattedContentID); if (enableFloat && hasStyleDependencies) { - writeChunk(destination, stringToChunk('",')); + writeChunk(destination, completeBoundaryScript2a); writeStyleResourceDependencies(destination, boundaryResources); } else { writeChunk(destination, completeBoundaryScript3); From 52d2e664f8054f5a0ae3a5701ef1b8a74bdc2e6f Mon Sep 17 00:00:00 2001 From: Josh Story Date: Thu, 29 Sep 2022 16:18:24 -0700 Subject: [PATCH 19/35] lint --- .../react-dom-bindings/src/client/ReactDOMFloatClient.js | 8 -------- 1 file changed, 8 deletions(-) diff --git a/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js b/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js index e63def25c1f81..3aaa8fe608f1d 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js +++ b/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js @@ -92,17 +92,9 @@ let currentDocument: ?Document = null; // preloads let lastCurrentDocument: ?Document = null; -// When the document Node that hosts style resources is removed from the tree and another one created -// the style Resources end up in a detatched state. We need to be able to restore them to the newly -// inserted hosts (html, head, or body, preferring head). However to simplify the logic we attempt -// restoration anytime a new Resource host mounts but we only want to restore once per commit. This -// boolean is used to flag that a restore should happen or be ignored and resets on each render -let stylesRestorable = true; - let previousDispatcher = null; export function prepareToRenderResources(ownerDocument: Document) { currentDocument = lastCurrentDocument = ownerDocument; - stylesRestorable = true; previousDispatcher = Dispatcher.current; Dispatcher.current = ReactDOMClientDispatcher; } From db245b09ea97d4cacb9dbbd257b4256171acabd5 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Thu, 29 Sep 2022 16:26:09 -0700 Subject: [PATCH 20/35] remove rootDidFlush --- packages/react-server/src/ReactFizzServer.js | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index a0e76c28bf044..dfc742bbc0c37 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -213,7 +213,6 @@ export opaque type Request = { pendingRootTasks: number, // when this reaches zero, we've finished at least the root boundary. resources: Resources, completedRootSegment: null | Segment, // Completed but not yet flushed root segments. - rootDidFlush: boolean, abortableTasks: Set, pingedTasks: Array, // High priority tasks that should be worked on first. // Queues to flush in order of priority @@ -291,7 +290,6 @@ export function createRequest( pendingRootTasks: 0, resources, completedRootSegment: null, - rootDidFlush: false, abortableTasks: abortSet, pingedTasks: pingedTasks, clientRenderedBoundaries: [], @@ -658,7 +656,7 @@ function hoistCompletedBoundaryResources( request: Request, completedBoundary: SuspenseBoundary, ): void { - if (!request.rootDidFlush) { + if (request.completedRootSegment !== null || request.pendingRootTasks > 0) { // The Shell has not flushed yet. we can hoist Resources for this boundary // all the way to the Root. hoistResourcesToRoot(request.resources, completedBoundary.resources); @@ -2261,13 +2259,12 @@ function flushCompletedQueues( flushSegment(request, destination, completedRootSegment); request.completedRootSegment = null; - request.rootDidFlush = true; writeCompletedRoot(destination, request.responseState); } else { // We haven't flushed the root yet so we don't need to check any other branches further down return; } - } else if (enableFloat && request.rootDidFlush) { + } else if (enableFloat) { flushImmediateResources(destination, request); } From 4987d19a9e9f10d982efccedb9cfe9369496d092 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Thu, 29 Sep 2022 16:28:19 -0700 Subject: [PATCH 21/35] fix test --- .../src/__tests__/ReactDOMFizzServer-test.js | 69 ------------------- 1 file changed, 69 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 01c2dadcd35a3..532a8da583f73 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -4376,75 +4376,6 @@ describe('ReactDOMFizzServer', () => { ); }); - // @gate enableFloat - it('does not emit as preamble after the first non-preamble chunk', async () => { - function AsyncNoOutput() { - readText('nooutput'); - return null; - } - function AsyncHead() { - readText('head'); - return ( - - a title - - ); - } - function AsyncBody() { - readText('body'); - return ( - - - hello - - ); - } - await actIntoEmptyDocument(() => { - const {pipe} = ReactDOMFizzServer.renderToPipeableStream( - - - - - , - ); - pipe(writable); - }); - await actIntoEmptyDocument(() => { - resolveText('body'); - }); - await actIntoEmptyDocument(() => { - resolveText('nooutput'); - }); - // We need to use actIntoEmptyDocument because act assumes that buffered - // content should be fake streamed into the body which is normally true - // but in this test the entire shell was delayed and we need the initial - // construction to be done to get the parsing right - await actIntoEmptyDocument(() => { - resolveText('head'); - }); - // This assertion is a little strange. The html open tag is part of the preamble - // but since the next chunk will be the body open tag which is not preamble it - // emits resources. The browser understands that the link is part of the head and - // constructs the head implicitly which is why it does not have the data-foo attribute. - // When the head finally streams in it is inside the body rather than after it because the - // body closing tag is part of the postamble which stays open until the entire request - // has flushed. This is how the browser would interpret a late head arriving after the - // the body closing tag so while strange it is the expected behavior. One other oddity - // is that in body is elided by html parsers so we end up with just an inlined - // style tag. - expect(getVisibleChildren(document)).toEqual( - - - - - - hello - a title - - , - ); - }); - // @gate enableFloat it('holds back body and html closing tags (the postamble) until all pending tasks are completed', async () => { const chunks = []; From 1dba4c073caace9fc6d91ec74f17e22e4ff4a615 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Thu, 29 Sep 2022 16:51:37 -0700 Subject: [PATCH 22/35] remove extraneous preamableopen --- .../react-dom-bindings/src/server/ReactDOMServerFormatConfig.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js index c438595882a11..910500d07c0ae 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js @@ -223,7 +223,6 @@ type InsertionMode = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7; export type FormatContext = { insertionMode: InsertionMode, // root/svg/html/mathml/table selectedValue: null | string | Array, // the selected value(s) inside a - preambleOpen: boolean, }; function createFormatContext( @@ -233,7 +232,6 @@ function createFormatContext( return { insertionMode, selectedValue, - preambleOpen: true, }; } From 3704af35541ea2d48769f86bd37c34fc2496e69b Mon Sep 17 00:00:00 2001 From: Josh Story Date: Thu, 29 Sep 2022 16:51:50 -0700 Subject: [PATCH 23/35] add react-dom external to flight entries --- scripts/rollup/bundles.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index 74522d9406f74..5081841959caf 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -359,7 +359,7 @@ const bundles = [ global: 'ReactServerDOMWriter', minifyWithProdErrorCodes: false, wrapWithModuleBoundaries: false, - externals: ['react'], + externals: ['react', 'react-dom'], }, { bundleTypes: [NODE_DEV, NODE_PROD], @@ -368,7 +368,7 @@ const bundles = [ global: 'ReactServerDOMWriter', minifyWithProdErrorCodes: false, wrapWithModuleBoundaries: false, - externals: ['react', 'util'], + externals: ['react', 'util', 'react-dom'], }, /******* React Server DOM Webpack Reader *******/ From 4d3069487851303820addf6d934b9988d07c5008 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Thu, 29 Sep 2022 20:56:00 -0700 Subject: [PATCH 24/35] types --- .../src/server/ReactDOMServerLegacyFormatConfig.js | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js b/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js index c3928bab646f8..7d84c54d7cb99 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js @@ -72,7 +72,6 @@ export function createRootFormatContext(): FormatContext { return { insertionMode: HTML_MODE, // We skip the root mode because we don't want to emit the DOCTYPE in legacy mode. selectedValue: null, - preambleOpen: true, }; } From 33fa6e1924404637897d01245c9aa157ee5b3c88 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Thu, 29 Sep 2022 21:05:01 -0700 Subject: [PATCH 25/35] make insert style script slightly smaller --- .../src/server/ReactDOMServerFormatConfig.js | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js index 910500d07c0ae..4861ba4ac3d90 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js @@ -2121,14 +2121,10 @@ export function writeEndSegment( // } // } -// if (dependencies.length) { -// Promise.all(dependencies).then( -// completeBoundary.bind(null, suspenseBoundaryID, contentID, ''), -// completeBoundary.bind(null, suspenseBoundaryID, contentID, "Resource failed to load") -// ); -// } else { -// completeBoundary(suspenseBoundaryID, contentID); -// } +// Promise.all(dependencies).then( +// completeBoundary.bind(null, suspenseBoundaryID, contentID, ''), +// completeBoundary.bind(null, suspenseBoundaryID, contentID, "Resource failed to load") +// ); // } // function completeBoundary(suspenseBoundaryID, contentID, errorDigest) { @@ -2219,7 +2215,7 @@ const completeSegmentFunction = const completeBoundaryFunction = 'function $RC(b,c,d){c=document.getElementById(c);c.parentNode.removeChild(c);var a=document.getElementById(b);if(a){b=a.previousSibling;if(d)b.data="$!",a.setAttribute("data-dgst",d);else{d=b.parentNode;a=b.nextSibling;var e=0;do{if(a&&a.nodeType===8){var h=a.data;if(h==="/$")if(0===e)break;else e--;else h!=="$"&&h!=="$?"&&h!=="$!"||e++}h=a.nextSibling;d.removeChild(a);a=h}while(a);for(;c.firstChild;)d.insertBefore(c.firstChild,a);b.data="$"}b._reactRetry&&b._reactRetry()}}'; const styleInsertionFunction = - '$RM=new Map;function $RR(b,c,d){function a(t){this.s=t}for(var e=new Map,h=document,p,n,m=h.querySelectorAll("link[data-rprec]"),l=0;n=m[l++];)e.set(n.dataset.rprec,p=n);n=0;m=[];for(var k,q,g,f;k=d[n++];){var r=0;q=k[r++];if(g=$RM.get(q))"l"!==g.s&&m.push(g);else{f=h.createElement("link");f.href=q;f.rel="stylesheet";for(f.dataset.rprec=l=k[r++];g=k[r++];)f.setAttribute(g,k[r++]);g=f._p=new Promise(function(t,u){f.onload=t;f.onerror=u});g.then(a.bind(g,"l"),a.bind(g,"e"));$RM.set(q,g);m.push(g);k=e.get(l)||p;k===p&&(p=f);e.set(l,f);k?k.parentNode.insertBefore(f,k.nextSibling):(l=h.head,l.insertBefore(f,l.firstChild))}}m.length?Promise.all(m).then($RC.bind(null,b,c,""),$RC.bind(null,b,c,"Resource failed to load")):$RC(b,c)}'; + '$RM=new Map;function $RR(p,q,t){function r(l){this.s=l}for(var m=new Map,n=document,g,e,f=n.querySelectorAll("link[data-rprec]"),d=0;e=f[d++];)m.set(e.dataset.rprec,g=e);e=0;f=[];for(var c,h,b,a;c=t[e++];){var k=0;h=c[k++];if(b=$RM.get(h))"l"!==b.s&&f.push(b);else{a=n.createElement("link");a.href=h;a.rel="stylesheet";for(a.dataset.rprec=d=c[k++];b=c[k++];)a.setAttribute(b,c[k++]);b=a._p=new Promise(function(l,u){a.onload=l;a.onerror=u});b.then(r.bind(b,"l"),r.bind(b,"e"));$RM.set(h,b);f.push(b);c=m.get(d)||g;c===g&&(g=a);m.set(d,a);c?c.parentNode.insertBefore(a,c.nextSibling):(d=n.head,d.insertBefore(a,d.firstChild))}}Promise.all(f).then($RC.bind(null,p,q,""),$RC.bind(null,p,q,"Resource failed to load"))}'; const clientRenderFunction = 'function $RX(b,c,d,e){var a=document.getElementById(b);a&&(b=a.previousSibling,b.data="$!",a=a.dataset,c&&(a.dgst=c),d&&(a.msg=d),e&&(a.stck=e),b._reactRetry&&b._reactRetry())}'; From a0bf1907c5b1f7bfd21c5b222b16436aa69b75be Mon Sep 17 00:00:00 2001 From: Josh Story Date: Fri, 30 Sep 2022 09:02:57 -0700 Subject: [PATCH 26/35] add react-dom externals --- scripts/rollup/bundles.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index 5081841959caf..df67873c8c485 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -275,7 +275,7 @@ const bundles = [ global: 'ReactDOMServer', minifyWithProdErrorCodes: true, wrapWithModuleBoundaries: false, - externals: ['react'], + externals: ['react', 'react-dom'], babel: opts => Object.assign({}, opts, { plugins: opts.plugins.concat([ @@ -288,7 +288,7 @@ const bundles = [ moduleType: RENDERER, entry: 'react-dom/src/server/ReactDOMLegacyServerNode.js', name: 'react-dom-server-legacy.node', - externals: ['react', 'stream'], + externals: ['react', 'stream', 'react-dom'], minifyWithProdErrorCodes: false, wrapWithModuleBoundaries: false, babel: opts => From 763fadc97bd668f59de458919fa49d76a436433e Mon Sep 17 00:00:00 2001 From: Josh Story Date: Fri, 30 Sep 2022 10:48:07 -0700 Subject: [PATCH 27/35] fix double call of hasStyleResourceDependencies --- .../react-dom-bindings/src/server/ReactDOMServerFormatConfig.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js index 4861ba4ac3d90..b1b3705a593a3 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js @@ -2280,7 +2280,7 @@ export function writeCompletedBoundaryInstruction( hasStyleDependencies = hasStyleResourceDependencies(boundaryResources); } writeChunk(destination, responseState.startInlineScript); - if (enableFloat && hasStyleResourceDependencies(boundaryResources)) { + if (enableFloat && hasStyleDependencies) { if (!responseState.sentCompleteBoundaryFunction) { responseState.sentCompleteBoundaryFunction = true; responseState.sentStyleInsertionFunction = true; From 3f3f9d53b041d3e04f76d4c36f4a59c5ecd5fc50 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Fri, 30 Sep 2022 09:55:21 -0700 Subject: [PATCH 28/35] remove special casing of precedence prop --- .../src/client/ReactDOMComponent.js | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/packages/react-dom-bindings/src/client/ReactDOMComponent.js b/packages/react-dom-bindings/src/client/ReactDOMComponent.js index defc3d93fc036..6c98f317aedd7 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMComponent.js +++ b/packages/react-dom-bindings/src/client/ReactDOMComponent.js @@ -73,7 +73,6 @@ import { enableTrustedTypesIntegration, enableCustomElementPropertySupport, enableClientRenderFallbackOnTextMismatch, - enableFloat, } from 'shared/ReactFeatureFlags'; import { mediaEventTypes, @@ -1019,17 +1018,6 @@ export function diffHydratedProperties( : getPropertyInfo(propKey); if (rawProps[SUPPRESS_HYDRATION_WARNING] === true) { // Don't bother comparing. We're ignoring all these warnings. - } else if ( - enableFloat && - tag === 'link' && - rawProps.rel === 'stylesheet' && - propKey === 'precedence' - ) { - // @TODO this is a temporary rule while we haven't implemented HostResources yet. This is used to allow - // for hydrating Resources (at the moment, stylesheets with a precedence prop) by using a data attribute. - // When we implement HostResources there will be no hydration directly so this code can be deleted - // $FlowFixMe - Should be inferred as not undefined. - extraAttributeNames.delete('data-rprec'); } else if ( propKey === SUPPRESS_CONTENT_EDITABLE_WARNING || propKey === SUPPRESS_HYDRATION_WARNING || From f1925a8832c571af9f3ddee2259dd46638173569 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Fri, 30 Sep 2022 10:46:47 -0700 Subject: [PATCH 29/35] escape hrefs in document queries --- .../src/client/ReactDOMFloatClient.js | 28 ++++- .../src/__tests__/ReactDOMFloat-test.js | 113 ++++++++++++++++++ 2 files changed, 138 insertions(+), 3 deletions(-) diff --git a/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js b/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js index 3aaa8fe608f1d..ac02747ddce21 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js +++ b/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js @@ -473,8 +473,11 @@ function createStyleResource( } } + const limitedEscacpedHref = escapeSelectorAttributeValueInsideDoubleQuotes( + href, + ); const existingEl = ownerDocument.querySelector( - `link[rel="stylesheet"][href="${href}"]`, + `link[rel="stylesheet"][href="${limitedEscacpedHref}"]`, ); const resource = { type: 'style', @@ -585,8 +588,11 @@ function createPreloadResource( href: string, props: PreloadProps, ): PreloadResource { + const limitedEscacpedHref = escapeSelectorAttributeValueInsideDoubleQuotes( + href, + ); let element = ownerDocument.querySelector( - `link[rel="preload"][href="${href}"]`, + `link[rel="preload"][href="${limitedEscacpedHref}"]`, ); if (!element) { element = createResourceInstance('link', props, ownerDocument); @@ -604,8 +610,11 @@ function createPreloadResource( function acquireStyleResource(resource: StyleResource): Instance { if (!resource.instance) { const {props, ownerDocument, precedence} = resource; + const limitedEscacpedHref = escapeSelectorAttributeValueInsideDoubleQuotes( + props.href, + ); const existingEl = ownerDocument.querySelector( - `link[rel="stylesheet"][data-rprec][href="${props.href}"]`, + `link[rel="stylesheet"][data-rprec][href="${limitedEscacpedHref}"]`, ); if (existingEl) { resource.instance = existingEl; @@ -801,3 +810,16 @@ export function isHostResourceType(type: string, props: Props): boolean { function isResourceAsType(as: mixed): boolean { return as === 'style' || as === 'font'; } + +// When passing user input into querySelector(All) the embedded string must not alter +// the semantics of the query. This escape function is safe to use when we know the +// provided value is going to be wrapped in double quotes as part of an attribute selector +// Do not use it anywhere else +// we escape double quotes and backslashes +const escapeSelectorAttributeValueInsideDoubleQuotesRegex = /[\"\\]/g; +function escapeSelectorAttributeValueInsideDoubleQuotes(value: string): string { + return value.replace( + escapeSelectorAttributeValueInsideDoubleQuotesRegex, + match => (match === '"' ? '\\"' : match === '\\' ? '\\\\' : ''), + ); +} diff --git a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js index c02d46973eb5e..9a878c73676ae 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js @@ -3483,4 +3483,117 @@ describe('ReactDOMFloat', () => { } }); }); + + describe('escaping', () => { + // @gate enableFloat + it('escapes hrefs when selecting matching elements in the document when rendering Resources', async () => { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + + + + +
    + + , + ); + pipe(writable); + }); + + container = document.getElementById('container'); + const root = ReactDOMClient.createRoot(container); + root.render( +
    + + + + foo +
    , + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + + + + + + + + + + +
    +
    foo
    +
    + + , + ); + }); + + // @gate enableFloat + it('escapes hrefs when selecting matching elements in the document when using preload and preinit', async () => { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + + + + +
    + + , + ); + pipe(writable); + }); + + function App() { + ReactDOM.preload('preload"][rel="preload', {as: 'style'}); + ReactDOM.preinit('style"][rel="stylesheet', { + as: 'style', + precedence: 'style', + }); + ReactDOM.preinit('with\\slashes', { + as: 'style', + precedence: 'style', + }); + return
    foo
    ; + } + + container = document.getElementById('container'); + const root = ReactDOMClient.createRoot(container); + root.render(); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + + + + + + + + + +
    +
    foo
    +
    + + , + ); + }); + }); }); From b845eff4794ef0059e8c7e5eb63769202788fb7b Mon Sep 17 00:00:00 2001 From: Josh Story Date: Fri, 30 Sep 2022 13:33:59 -0700 Subject: [PATCH 30/35] use a stack for currentResources --- .../src/server/ReactDOMFloatServer.js | 22 +++++++--------- .../src/server/ReactDOMServerFormatConfig.js | 25 +++++++++++++------ packages/react-server/src/ReactFizzServer.js | 5 ++-- 3 files changed, 29 insertions(+), 23 deletions(-) diff --git a/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js b/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js index 210093e5e4bbf..3fa4b8fc0fe9a 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js +++ b/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js @@ -100,13 +100,16 @@ export function mergeBoundaryResources( } let currentResources: null | Resources = null; +let currentResourcesStack = []; let previousDispatcher = null; export function prepareToRenderResources(resources: Resources) { + currentResourcesStack.push(currentResources); currentResources = resources; +} - previousDispatcher = ReactDOMSharedInternals.Dispatcher.current; - ReactDOMSharedInternals.Dispatcher.current = Dispatcher; +export function finishRenderingResources() { + currentResources = currentResourcesStack.pop(); } export function setCurrentlyRenderingBoundaryResourcesTarget( @@ -116,12 +119,10 @@ export function setCurrentlyRenderingBoundaryResourcesTarget( resources.boundaryResources = boundaryResources; } -export function finishRenderingResources() { - currentResources = null; - - ReactDOMSharedInternals.Dispatcher.current = previousDispatcher; - previousDispatcher = null; -} +export const ReactDOMServerDispatcher = { + preload, + preinit, +}; type PreloadAs = ResourceType; type PreloadOptions = {as: PreloadAs, crossOrigin?: string}; @@ -574,8 +575,3 @@ export function hoistResourcesToRoot( }); boundaryResources.clear(); } - -const Dispatcher = { - preload, - preinit, -}; diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js index b1b3705a593a3..7c236326e1837 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js @@ -64,6 +64,7 @@ import { prepareToRenderResources, finishRenderingResources, resourcesFromLink, + ReactDOMServerDispatcher, } from './ReactDOMFloatServer'; export { createResources, @@ -73,6 +74,22 @@ export { hoistResourcesToRoot, } from './ReactDOMFloatServer'; +import ReactDOMSharedInternals from 'shared/ReactDOMSharedInternals'; +const ReactDOMCurrentDispatcher = ReactDOMSharedInternals.Dispatcher; + +export function prepareToRender(resources: Resources): mixed { + prepareToRenderResources(resources); + + let previousHostDispatcher = ReactDOMCurrentDispatcher.current; + ReactDOMCurrentDispatcher.current = ReactDOMServerDispatcher; + return previousHostDispatcher; +} + +export function cleanupAfterRender(previousDispatcher: mixed) { + finishRenderingResources(); + ReactDOMCurrentDispatcher.current = previousDispatcher; +} + // 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; @@ -2722,11 +2739,3 @@ function writeStyleResourceAttribute( stringToChunk(escapeJSObjectForInstructionScripts(attributeValue)), ); } - -export function prepareToRender(resources: Resources) { - prepareToRenderResources(resources); -} - -export function cleanupAfterRender() { - finishRenderingResources(); -} diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index dfc742bbc0c37..ab9415d88d369 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -1827,7 +1827,6 @@ function finishedTask( } function retryTask(request: Request, task: Task): void { - prepareToRender(request.resources); if (enableFloat) { const blockedBoundary = task.blockedBoundary; setCurrentlyRenderingBoundaryResourcesTarget( @@ -1892,7 +1891,6 @@ function retryTask(request: Request, task: Task): void { if (__DEV__) { currentTaskInDEV = prevTaskInDEV; } - cleanupAfterRender(); } } @@ -1903,6 +1901,7 @@ export function performWork(request: Request): void { const prevContext = getActiveContext(); const prevDispatcher = ReactCurrentDispatcher.current; ReactCurrentDispatcher.current = Dispatcher; + const previousHostDispatcher = prepareToRender(request.resources); let prevGetCurrentStackImpl; if (__DEV__) { prevGetCurrentStackImpl = ReactDebugCurrentFrame.getCurrentStack; @@ -1927,6 +1926,8 @@ export function performWork(request: Request): void { } finally { setCurrentResponseState(prevResponseState); ReactCurrentDispatcher.current = prevDispatcher; + cleanupAfterRender(previousHostDispatcher); + if (__DEV__) { ReactDebugCurrentFrame.getCurrentStack = prevGetCurrentStackImpl; } From 7c454200c66a080c0cc2b39c988ad1912d8dec32 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Fri, 30 Sep 2022 13:35:17 -0700 Subject: [PATCH 31/35] lints --- packages/react-dom-bindings/src/server/ReactDOMFloatServer.js | 4 +--- .../src/server/ReactDOMServerFormatConfig.js | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js b/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js index 3fa4b8fc0fe9a..aef1d3553959f 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js +++ b/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js @@ -7,7 +7,6 @@ * @flow */ -import ReactDOMSharedInternals from 'shared/ReactDOMSharedInternals'; import { validatePreloadResourceDifference, validateStyleResourceDifference, @@ -100,9 +99,8 @@ export function mergeBoundaryResources( } let currentResources: null | Resources = null; -let currentResourcesStack = []; +const currentResourcesStack = []; -let previousDispatcher = null; export function prepareToRenderResources(resources: Resources) { currentResourcesStack.push(currentResources); currentResources = resources; diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js index 7c236326e1837..cedc935f7ac26 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js @@ -80,7 +80,7 @@ const ReactDOMCurrentDispatcher = ReactDOMSharedInternals.Dispatcher; export function prepareToRender(resources: Resources): mixed { prepareToRenderResources(resources); - let previousHostDispatcher = ReactDOMCurrentDispatcher.current; + const previousHostDispatcher = ReactDOMCurrentDispatcher.current; ReactDOMCurrentDispatcher.current = ReactDOMServerDispatcher; return previousHostDispatcher; } From 17fde2c7dfd4a651aed3e9216ab7dbf037975fde Mon Sep 17 00:00:00 2001 From: Josh Story Date: Fri, 30 Sep 2022 13:41:01 -0700 Subject: [PATCH 32/35] flow --- .../src/client/ReactDOMFloatClient.js | 12 ++++++------ .../src/server/ReactNativeServerFormatConfig.js | 2 +- .../src/forks/ReactServerFormatConfig.custom.js | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js b/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js index ac02747ddce21..28dcbb2df3ee2 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js +++ b/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js @@ -473,11 +473,11 @@ function createStyleResource( } } - const limitedEscacpedHref = escapeSelectorAttributeValueInsideDoubleQuotes( + const limitedEscapedHref = escapeSelectorAttributeValueInsideDoubleQuotes( href, ); const existingEl = ownerDocument.querySelector( - `link[rel="stylesheet"][href="${limitedEscacpedHref}"]`, + `link[rel="stylesheet"][href="${limitedEscapedHref}"]`, ); const resource = { type: 'style', @@ -588,11 +588,11 @@ function createPreloadResource( href: string, props: PreloadProps, ): PreloadResource { - const limitedEscacpedHref = escapeSelectorAttributeValueInsideDoubleQuotes( + const limitedEscapedHref = escapeSelectorAttributeValueInsideDoubleQuotes( href, ); let element = ownerDocument.querySelector( - `link[rel="preload"][href="${limitedEscacpedHref}"]`, + `link[rel="preload"][href="${limitedEscapedHref}"]`, ); if (!element) { element = createResourceInstance('link', props, ownerDocument); @@ -610,11 +610,11 @@ function createPreloadResource( function acquireStyleResource(resource: StyleResource): Instance { if (!resource.instance) { const {props, ownerDocument, precedence} = resource; - const limitedEscacpedHref = escapeSelectorAttributeValueInsideDoubleQuotes( + const limitedEscapedHref = escapeSelectorAttributeValueInsideDoubleQuotes( props.href, ); const existingEl = ownerDocument.querySelector( - `link[rel="stylesheet"][data-rprec][href="${limitedEscacpedHref}"]`, + `link[rel="stylesheet"][data-rprec][href="${limitedEscapedHref}"]`, ); if (existingEl) { resource.instance = existingEl; diff --git a/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js b/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js index cc0ecde062b3d..4a7702e703120 100644 --- a/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js +++ b/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js @@ -342,7 +342,7 @@ export function hoistResourcesToRoot( ) {} export function prepareToRender(resources: Resources) {} -export function cleanupAfterRender() {} +export function cleanupAfterRender(previousDispatcher: mixed) {} export function createResources() {} export function createBoundaryResources() {} export function setCurrentlyRenderingBoundaryResourcesTarget( diff --git a/packages/react-server/src/forks/ReactServerFormatConfig.custom.js b/packages/react-server/src/forks/ReactServerFormatConfig.custom.js index 3fb9e30d543e3..a074618c0e2aa 100644 --- a/packages/react-server/src/forks/ReactServerFormatConfig.custom.js +++ b/packages/react-server/src/forks/ReactServerFormatConfig.custom.js @@ -68,6 +68,8 @@ export const writeCompletedBoundaryInstruction = $$$hostConfig.writeCompletedBoundaryInstruction; export const writeClientRenderBoundaryInstruction = $$$hostConfig.writeClientRenderBoundaryInstruction; +export const prepareToRender = $$$hostConfig.prepareToRender; +export const cleanupAfterRender = $$$hostConfig.cleanupAfterRender; // ------------------------- // Resources @@ -76,8 +78,6 @@ export const writeInitialResources = $$$hostConfig.writeInitialResources; export const writeImmediateResources = $$$hostConfig.writeImmediateResources; export const hoistResources = $$$hostConfig.hoistResources; export const hoistResourcesToRoot = $$$hostConfig.hoistResourcesToRoot; -export const prepareToRender = $$$hostConfig.prepareToRender; -export const cleanupAfterRender = $$$hostConfig.cleanupAfterRender; export const createResources = $$$hostConfig.createResources; export const createBoundaryResources = $$$hostConfig.createBoundaryResources; export const setCurrentlyRenderingBoundaryResourcesTarget = From b5449279548c4561d3a953d09b79e048a9ac8db6 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Fri, 30 Sep 2022 15:58:04 -0700 Subject: [PATCH 33/35] escape newline in attribute selector --- .../react-dom-bindings/src/client/ReactDOMFloatClient.js | 4 ++-- packages/react-dom/src/__tests__/ReactDOMFloat-test.js | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js b/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js index 28dcbb2df3ee2..84b6ffca1def0 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js +++ b/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js @@ -816,10 +816,10 @@ function isResourceAsType(as: mixed): boolean { // provided value is going to be wrapped in double quotes as part of an attribute selector // Do not use it anywhere else // we escape double quotes and backslashes -const escapeSelectorAttributeValueInsideDoubleQuotesRegex = /[\"\\]/g; +const escapeSelectorAttributeValueInsideDoubleQuotesRegex = /[\n\"\\]/g; function escapeSelectorAttributeValueInsideDoubleQuotes(value: string): string { return value.replace( escapeSelectorAttributeValueInsideDoubleQuotesRegex, - match => (match === '"' ? '\\"' : match === '\\' ? '\\\\' : ''), + ch => '\\' + ch.charCodeAt(0).toString(16), ); } diff --git a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js index 9a878c73676ae..3484c172c4cf2 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js @@ -3495,6 +3495,7 @@ describe('ReactDOMFloat', () => { +
    , @@ -3513,6 +3514,7 @@ describe('ReactDOMFloat', () => { precedence="style" /> + foo
    , ); @@ -3521,6 +3523,7 @@ describe('ReactDOMFloat', () => { + { +
    , @@ -3567,6 +3571,7 @@ describe('ReactDOMFloat', () => { as: 'style', precedence: 'style', }); + ReactDOM.preload('with\nnewline', {as: 'style'}); return
    foo
    ; } @@ -3578,6 +3583,7 @@ describe('ReactDOMFloat', () => { + Date: Fri, 30 Sep 2022 15:58:57 -0700 Subject: [PATCH 34/35] remove unecessary export --- packages/react-dom-bindings/src/client/ReactDOMHostConfig.js | 4 +--- .../src/ReactFiberHostConfigWithNoResources.js | 1 - .../react-reconciler/src/forks/ReactFiberHostConfig.custom.js | 1 - 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js b/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js index 38be54d68a446..cc0cc075cd23f 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js @@ -1348,9 +1348,7 @@ export function requestPostPaintCallback(callback: (time: number) => void) { export const supportsResources = true; export {isHostResourceType}; -export function isHostResourceInstance( - instance: Instance | Container, -): boolean { +function isHostResourceInstance(instance: Instance | Container): boolean { if (instance.nodeType === ELEMENT_NODE) { switch (instance.tagName.toLowerCase()) { case 'link': { diff --git a/packages/react-reconciler/src/ReactFiberHostConfigWithNoResources.js b/packages/react-reconciler/src/ReactFiberHostConfigWithNoResources.js index 0b194ef099448..b620a06b37fb9 100644 --- a/packages/react-reconciler/src/ReactFiberHostConfigWithNoResources.js +++ b/packages/react-reconciler/src/ReactFiberHostConfigWithNoResources.js @@ -20,7 +20,6 @@ function shim(...args: any) { // Resources (when unsupported) export const supportsResources = false; -export const isHostResourceInstance = shim; export const isHostResourceType = shim; export const getResource = shim; export const acquireResource = shim; diff --git a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js index 2f1520dbb5e21..4bb920ca8b78a 100644 --- a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js @@ -195,7 +195,6 @@ export const errorHydratingContainer = $$$hostConfig.errorHydratingContainer; // (optional) // ------------------- export const supportsResources = $$$hostConfig.supportsResources; -export const isHostResourceInstance = $$$hostConfig.isHostResourceInstance; export const isHostResourceType = $$$hostConfig.isHostResourceType; export const getResource = $$$hostConfig.getResource; export const acquireResource = $$$hostConfig.acquireResource; From 1ccdb0098465bc9bb2ac0c02f0c21204cb6a7e47 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Fri, 30 Sep 2022 16:04:16 -0700 Subject: [PATCH 35/35] nits --- packages/react-dom-bindings/src/client/ReactDOMFloatClient.js | 2 +- packages/react-dom-bindings/src/client/ReactDOMHostConfig.js | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js b/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js index 84b6ffca1def0..716c2c679eba8 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js +++ b/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js @@ -296,7 +296,7 @@ type PreloadQualifyingProps = { [string]: mixed, }; -// This function is called in complete work and we should always have a currentDocument set +// This function is called in begin work and we should always have a currentDocument set export function getResource( type: string, pendingProps: Props, diff --git a/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js b/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js index cc0cc075cd23f..1d5f27089ae8c 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js @@ -782,8 +782,7 @@ function getNextHydratable(node) { continue; } break; - } - if (nodeType === TEXT_NODE) { + } else if (nodeType === TEXT_NODE) { break; } } else {