diff --git a/packages/react-art/src/ReactARTHostConfig.js b/packages/react-art/src/ReactARTHostConfig.js index dbaba801b1..aa3925cdbe 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') { @@ -455,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-dom-bindings/src/client/ReactDOMComponent.js b/packages/react-dom-bindings/src/client/ReactDOMComponent.js index defc3d93fc..6c98f317ae 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 || diff --git a/packages/react-dom-bindings/src/client/ReactDOMComponentTree.js b/packages/react-dom-bindings/src/client/ReactDOMComponentTree.js index 58e5d72acd..8752959466 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 0000000000..716c2c679e --- /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'; +const {Dispatcher} = ReactDOMSharedInternals; +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; + +let previousDispatcher = null; +export function prepareToRenderResources(ownerDocument: Document) { + currentDocument = lastCurrentDocument = ownerDocument; + previousDispatcher = Dispatcher.current; + Dispatcher.current = ReactDOMClientDispatcher; +} + +export function cleanupAfterRenderResources() { + currentDocument = null; + Dispatcher.current = previousDispatcher; + previousDispatcher = null; +} + +// 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(); +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 begin 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 limitedEscapedHref = escapeSelectorAttributeValueInsideDoubleQuotes( + href, + ); + const existingEl = ownerDocument.querySelector( + `link[rel="stylesheet"][href="${limitedEscapedHref}"]`, + ); + const resource = { + type: 'style', + count: 0, + href, + precedence, + props, + hint: null, + preloaded: false, + loaded: false, + error: false, + ownerDocument, + instance: null, + }; + 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.loaded === false && 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 { + const limitedEscapedHref = escapeSelectorAttributeValueInsideDoubleQuotes( + href, + ); + let element = ownerDocument.querySelector( + `link[rel="preload"][href="${limitedEscapedHref}"]`, + ); + 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 limitedEscapedHref = escapeSelectorAttributeValueInsideDoubleQuotes( + props.href, + ); + const existingEl = ownerDocument.querySelector( + `link[rel="stylesheet"][data-rprec][href="${limitedEscapedHref}"]`, + ); + 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 { + // @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); + } else { + throw new Error( + 'While attempting to insert a Resource, React expected the Document to contain' + + ' a head element but it was not found.', + ); + } + } +} + +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'; +} + +// 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 = /[\n\"\\]/g; +function escapeSelectorAttributeValueInsideDoubleQuotes(value: string): string { + return value.replace( + escapeSelectorAttributeValueInsideDoubleQuotesRegex, + ch => '\\' + ch.charCodeAt(0).toString(16), + ); +} diff --git a/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js b/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js index 64195ac7f5..1d5f27089a 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,12 @@ 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, +} from './ReactDOMFloatClient'; + export type Type = string; export type Props = { autoFocus?: boolean, @@ -680,14 +690,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 +763,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,15 +778,11 @@ 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; - } - if (nodeType === TEXT_NODE) { + } else if (nodeType === TEXT_NODE) { break; } } else { @@ -903,43 +889,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 +1230,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 +1340,45 @@ export function requestPostPaintCallback(callback: (time: number) => void) { localRequestAnimationFrame(time => callback(time)); }); } +// ------------------- +// Resources +// ------------------- + +export const supportsResources = true; + +export {isHostResourceType}; +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 0b1f1c498e..06c42b82e2 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 0000000000..aef1d35539 --- /dev/null +++ b/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js @@ -0,0 +1,575 @@ +/** + * 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 { + 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, +}; + +// @TODO add bootstrap script to implicit preloads +export function createResources(): Resources { + return { + // persistent + preloadsMap: new Map(), + stylesMap: new Map(), + + // cleared on flush + explicitPreloads: new Set(), + implicitPreloads: new Set(), + 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: null | Resources = null; +const currentResourcesStack = []; + +export function prepareToRenderResources(resources: Resources) { + currentResourcesStack.push(currentResources); + currentResources = resources; +} + +export function finishRenderingResources() { + currentResources = currentResourcesStack.pop(); +} + +export function setCurrentlyRenderingBoundaryResourcesTarget( + resources: Resources, + boundaryResources: null | BoundaryResources, +) { + resources.boundaryResources = boundaryResources; +} + +export const ReactDOMServerDispatcher = { + preload, + preinit, +}; + +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(); +} diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js index c30d967326..cedc935f7a 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,36 @@ import hasOwnProperty from 'shared/hasOwnProperty'; import sanitizeURL from '../shared/sanitizeURL'; import isArray from 'shared/isArray'; +import { + prepareToRenderResources, + finishRenderingResources, + resourcesFromLink, + ReactDOMServerDispatcher, +} from './ReactDOMFloatServer'; +export { + createResources, + createBoundaryResources, + setCurrentlyRenderingBoundaryResourcesTarget, + hoistResources, + hoistResourcesToRoot, +} from './ReactDOMFloatServer'; + +import ReactDOMSharedInternals from 'shared/ReactDOMSharedInternals'; +const ReactDOMCurrentDispatcher = ReactDOMSharedInternals.Dispatcher; + +export function prepareToRender(resources: Resources): mixed { + prepareToRenderResources(resources); + + const 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; @@ -73,7 +105,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 +216,7 @@ export function createResponseState( sentCompleteSegmentFunction: false, sentCompleteBoundaryFunction: false, sentClientRenderFunction: false, + sentStyleInsertionFunction: false, }; } @@ -1088,6 +1122,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 +1160,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 } @@ -1270,10 +1318,12 @@ function pushStartHead( tag: string, responseState: ResponseState, ): ReactNodeList { - // Preamble type is nullable for feature off cases but is guaranteed when feature is on - target = enableFloat ? preamble : target; - - return pushStartGenericElement(target, props, tag, responseState); + return pushStartGenericElement( + enableFloat ? preamble : target, + props, + tag, + responseState, + ); } function pushStartHtml( @@ -1281,12 +1331,10 @@ 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) { // 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 @@ -1517,6 +1565,7 @@ export function pushStartInstance( props: Object, responseState: ResponseState, formatContext: FormatContext, + textEmbedded: boolean, ): ReactNodeList { if (__DEV__) { validateARIAProperties(type, props); @@ -1570,7 +1619,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': { @@ -1613,8 +1662,8 @@ export function pushStartInstance( preamble, props, type, - formatContext, responseState, + formatContext, ); } default: { @@ -1658,17 +1707,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( @@ -1977,7 +2033,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); @@ -2000,14 +2058,100 @@ export function writeEndSegment( // suspenseNode._reactRetry(); // } // } -// -// function completeBoundary(suspenseBoundaryID, contentID) { -// // Find the fallback's first element. -// const suspenseIdNode = document.getElementById(suspenseBoundaryID); + +// resourceMap = new Map(); +// function completeBoundaryWithStyles(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 = 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 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 +// } +// 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); +// } +// } + +// Promise.all(dependencies).then( +// completeBoundary.bind(null, suspenseBoundaryID, contentID, ''), +// completeBoundary.bind(null, suspenseBoundaryID, contentID, "Resource failed to load") +// ); +// } + +// 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 @@ -2016,51 +2160,57 @@ export function writeEndSegment( // } // // 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--; + +// 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++; // } -// } 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); // } -// -// 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) // } -// suspenseNode.data = SUSPENSE_START_DATA; // if (suspenseNode._reactRetry) { // suspenseNode._reactRetry(); // } // } -// + // function completeSegment(containerID, placeholderID) { // const segmentContainer = document.getElementById(containerID); // const placeholderNode = document.getElementById(placeholderID); @@ -2080,7 +2230,9 @@ 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()}}'; + '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(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())}'; @@ -2118,23 +2270,51 @@ const completeBoundaryScript1Full = stringToPrecomputedChunk( completeBoundaryFunction + ';$RC("', ); const completeBoundaryScript1Partial = stringToPrecomputedChunk('$RC("'); + +const completeBoundaryWithStylesScript1FullBoth = stringToPrecomputedChunk( + completeBoundaryFunction + ';' + styleInsertionFunction + ';$RR("', +); +const completeBoundaryWithStylesScript1FullPartial = stringToPrecomputedChunk( + styleInsertionFunction + ';$RR("', +); +const completeBoundaryWithStylesScript1Partial = stringToPrecomputedChunk( + '$RR("', +); const completeBoundaryScript2 = stringToPrecomputedChunk('","'); -const completeBoundaryScript3 = stringToPrecomputedChunk('")'); +const completeBoundaryScript2a = 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 && hasStyleDependencies) { + if (!responseState.sentCompleteBoundaryFunction) { + responseState.sentCompleteBoundaryFunction = true; + responseState.sentStyleInsertionFunction = true; + writeChunk(destination, completeBoundaryWithStylesScript1FullBoth); + } else if (!responseState.sentStyleInsertionFunction) { + responseState.sentStyleInsertionFunction = true; + writeChunk(destination, completeBoundaryWithStylesScript1FullPartial); + } else { + writeChunk(destination, completeBoundaryWithStylesScript1Partial); + } } else { - // Future calls can just reuse the same function. - writeChunk(destination, completeBoundaryScript1Partial); + if (!responseState.sentCompleteBoundaryFunction) { + responseState.sentCompleteBoundaryFunction = true; + writeChunk(destination, completeBoundaryScript1Full); + } else { + writeChunk(destination, completeBoundaryScript1Partial); + } } if (boundaryID === null) { @@ -2148,7 +2328,13 @@ export function writeCompletedBoundaryInstruction( writeChunk(destination, completeBoundaryScript2); writeChunk(destination, responseState.segmentPrefix); writeChunk(destination, formattedContentID); - return writeChunkAndReturn(destination, completeBoundaryScript3); + if (enableFloat && hasStyleDependencies) { + writeChunk(destination, completeBoundaryScript2a); + writeStyleResourceDependencies(destination, boundaryResources); + } else { + writeChunk(destination, completeBoundaryScript3); + } + return writeChunkAndReturn(destination, completeBoundaryScript4); } const clientRenderScript1Full = stringToPrecomputedChunk( @@ -2209,10 +2395,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 +2416,326 @@ 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)), + ); +} diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js b/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js index 375562e80b..7d84c54d7c 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, }; @@ -74,6 +76,8 @@ export function createRootFormatContext(): FormatContext { } export type { + Resources, + BoundaryResources, FormatContext, SuspenseBoundaryID, } from './ReactDOMServerFormatConfig'; @@ -96,6 +100,15 @@ export { writeEndPendingSuspenseBoundary, writePlaceholder, writeCompletedRoot, + createResources, + createBoundaryResources, + writeInitialResources, + writeImmediateResources, + hoistResources, + hoistResourcesToRoot, + setCurrentlyRenderingBoundaryResourcesTarget, + 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 0000000000..d8729ec7fe --- /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 0000000000..3fabd9fb92 --- /dev/null +++ b/packages/react-dom-bindings/src/shared/ReactDOMFloat.js @@ -0,0 +1,21 @@ +import ReactDOMSharedInternals from 'shared/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 0000000000..111803bde4 --- /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 11cb1a262f..e8f165a72a 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 9e678ea2a9..12cb9dc000 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'; - -const Internals = { - usingClientEntryPoint: false, - // Keep in sync with ReactTestUtils.js. - // This is an array for better minification. - Events: [ - getInstanceFromNode, - getNodeFromInstance, - getFiberCurrentPropsFromNode, - enqueueStateRestore, - restoreStateIfNeeded, - batchedUpdates, - ], +type InternalsType = { + usingClientEntryPoint: boolean, + Events: [any, any, any, any, any, any], + Dispatcher: { + current: mixed, + }, }; +const Internals: InternalsType = ({ + usingClientEntryPoint: false, + Events: null, + Dispatcher: { + current: null, + }, +}: any); + export default Internals; diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index f89074a831..532a8da583 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...
@@ -4319,70 +4320,60 @@ 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}, - ); - 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 @@ -4431,267 +4422,6 @@ describe('ReactDOMFizzServer', () => { expect(chunks.pop()).toEqual('</body></html>'); }); - // @gate enableFloat - it('recognizes stylesheet links as attributes during hydration', async () => { - await actIntoEmptyDocument(() => { - const {pipe} = ReactDOMFizzServer.renderToPipeableStream( - <> - <link rel="stylesheet" href="foo" precedence="default" /> - <html> - <head> - <link rel="author" precedence="this is a nonsense prop" /> - </head> - <body>a body</body> - </html> - </>, - ); - 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( - <html> - <head> - <link rel="stylesheet" href="foo" data-rprec="default" /> - <link rel="author" precedence="this is a nonsense prop" /> - </head> - <body>a body</body> - </html>, - ); - - // It hydrates successfully - const root = ReactDOMClient.hydrateRoot( - document, - <> - <link rel="stylesheet" href="foo" precedence="default" /> - <html> - <head> - <link rel="author" precedence="this is a nonsense prop" /> - </head> - <body>a body</body> - </html> - </>, - ); - // 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( - <html> - <head> - <link rel="stylesheet" href="foo" data-rprec="default" /> - <link rel="author" precedence="this is a nonsense prop" /> - </head> - <body>a body</body> - </html>, - ); - } catch (e) { - uncaughtErrors.push(e); - } - try { - expect(Scheduler).toFlushWithoutYielding(); - } catch (e) { - uncaughtErrors.push(e); - } - - root.render( - <> - <link rel="stylesheet" href="foo" precedence="default" data-bar="bar" /> - <html> - <head /> - <body>a body</body> - </html> - </>, - ); - try { - expect(Scheduler).toFlushWithoutYielding(); - expect(getVisibleChildren(document)).toEqual( - <html> - <head> - <link - rel="stylesheet" - href="foo" - data-rprec="default" - data-bar="bar" - /> - </head> - <body>a body</body> - </html>, - ); - } 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( - <html> - <body>foo</body> - </html>, - ); - pipe(writable); - }); - - const uncaughtErrors = []; - ReactDOMClient.hydrateRoot( - document, - <> - <link rel="stylesheet" href="foo" precedence="foo" /> - <html> - <head /> - <body>foo</body> - </html> - </>, - ); - try { - expect(Scheduler).toFlushWithoutYielding(); - expect(getVisibleChildren(document)).toEqual( - <html> - <head> - <link rel="stylesheet" href="foo" precedence="foo" /> - </head> - <body>foo</body> - </html>, - ); - } 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( - <html> - <head /> - <body>a body</body> - </html>, - ); - pipe(writable); - }); - - const errors = []; - ReactDOMClient.hydrateRoot( - document, - <html> - <head> - <link rel="stylesheet" href="foo" precedence="low" /> - </head> - <body>a body</body> - </html>, - { - onRecoverableError(err, errInfo) { - errors.push(err.message); - }, - }, - ); - expect(() => { - expect(Scheduler).toFlushWithoutYielding(); - }).toErrorDev( - [ - 'Warning: A matching Hydratable Resource was not found in the DOM for <link rel="stylesheet" href="foo">', - '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( - <> - <link rel="stylesheet" href="foo" precedence="low" /> - <link rel="stylesheet" href="foo" precedence="high" /> - <html> - <head /> - <body>a body</body> - </html> - </>, - ); - pipe(writable); - }); - expect(getVisibleChildren(document)).toEqual( - <html> - <head> - <link rel="stylesheet" href="foo" data-rprec="low" /> - <link rel="stylesheet" href="foo" data-rprec="high" /> - </head> - <body>a body</body> - </html>, - ); - - const errors = []; - ReactDOMClient.hydrateRoot( - document, - <> - <html> - <head> - <link rel="stylesheet" href="foo" precedence="low" /> - <link rel="stylesheet" href="foo" precedence="high" /> - </head> - <body>a body</body> - </html> - </>, - { - 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 0000000000..3484c172c4 --- /dev/null +++ b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js @@ -0,0 +1,3605 @@ +/** + * 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( + '<!DOCTYPE html><html><head></head><body><div id="container">', + { + 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); + } + + // @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); + root.render( + <html> + <body> + <link rel="stylesheet" href="foo" precedence="default" /> + foo + </body> + </html>, + ); + 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( + <div> + <link rel="stylesheet" href="foo" precedence="foo" /> + </div>, + ); + expect(Scheduler).toFlushWithoutYielding(); + root.render( + <div> + <link rel="author" href="bar" /> + </div>, + ); + expect(() => { + expect(Scheduler).toFlushWithoutYielding(); + }).toErrorDev( + 'Warning: A <link> 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( + <html> + <head> + <link rel="stylesheet" href="foo" data-rprec="foo" /> + <link rel="preload" as="style" href="foo" /> + </head> + <body> + <div id="container"> + <div /> + </div> + </body> + </html>, + ); + }); + }); + + 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( + <html> + <head /> + <body> + <Component /> + </body> + </html>, + ); + pipe(writable); + }); + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link rel="preload" as="style" href="foo" /> + </head> + <body>foo</body> + </html>, + ); + }); + + // @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(<Component />); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link rel="preload" as="style" href="foo" /> + </head> + <body> + <div id="container">foo</div> + </body> + </html>, + ); + }); + + // @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(<App />); + expect(Scheduler).toFlushWithoutYielding(); + + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link rel="preload" as="style" href="foo" /> + </head> + <body> + <div id="container">foobar</div> + </body> + </html>, + ); + }); + + // @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(<App />); + expect(Scheduler).toFlushWithoutYielding(); + + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link rel="preload" as="style" href="foo" /> + </head> + <body> + <div id="container">foobar</div> + </body> + </html>, + ); + }); + + // @gate enableFloat + 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 + // global does not point to the global.document + expect(getVisibleChildren(global.document)).toEqual( + <html> + <head> + <link rel="preload" as="style" href="bar" /> + </head> + <body /> + </html>, + ); + }); + }); + + 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( + <html> + <head /> + <body> + <Component /> + </body> + </html>, + ); + pipe(writable); + }); + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link rel="stylesheet" href="foo" data-rprec="foo" /> + </head> + <body>foo</body> + </html>, + ); + }); + + // @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( + <html> + <head /> + <body> + <Suspense fallback="loading..."> + <BlockedOn text="unblock"> + <Component /> + </BlockedOn> + </Suspense> + </body> + </html>, + ); + pipe(writable); + }); + await act(() => { + resolveText('unblock'); + }); + expect(getVisibleChildren(document)).toEqual( + <html> + <head /> + <body> + foo + <link rel="preload" as="style" href="foo" /> + </body> + </html>, + ); + }); + + // @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(<Component />); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link rel="stylesheet" href="foo" data-rprec="foo" /> + </head> + <body> + <div id="container">foo</div> + </body> + </html>, + ); + }); + + // @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(<App />); + expect(Scheduler).toFlushWithoutYielding(); + + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link rel="preload" as="style" href="insertion" /> + <link rel="preload" as="style" href="layout" /> + <link rel="preload" as="style" href="passive" /> + </head> + <body> + <div id="container">foobar</div> + </body> + </html>, + ); + }); + + // @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 + // global does not point to the global.document + expect(getVisibleChildren(global.document)).toEqual( + <html> + <head> + <link rel="preload" as="style" href="bar" /> + </head> + <body /> + </html>, + ); + }); + }); + + 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( + <html> + <head /> + <body> + <link rel="stylesheet" href="aresource" precedence="foo" /> + <div>hello world</div> + </body> + </html>, + ); + pipe(writable); + }); + + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link rel="stylesheet" href="aresource" data-rprec="foo" /> + </head> + <body> + <div>hello world</div> + </body> + </html>, + ); + }); + + // @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( + <html> + <head /> + <body> + <link rel="stylesheet" href="aresource" precedence="foo" /> + <div>hello world</div> + </body> + </html>, + ); + expect(Scheduler).toFlushWithoutYielding(); + + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link rel="stylesheet" href="aresource" data-rprec="foo" /> + </head> + <body> + <div>hello world</div> + </body> + </html>, + ); + }); + + // @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( + <html> + <head /> + <body> + <link rel="stylesheet" href="aresource" precedence="foo" /> + <div>hello world</div> + </body> + </html>, + ); + pipe(writable); + }); + ReactDOMClient.hydrateRoot( + document, + <html> + <head /> + <body> + <link rel="stylesheet" href="aresource" precedence="foo" /> + <div>hello world</div> + </body> + </html>, + ); + expect(Scheduler).toFlushWithoutYielding(); + + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link rel="stylesheet" href="aresource" data-rprec="foo" /> + </head> + <body> + <div>hello world</div> + </body> + </html>, + ); + }); + + // @gate enableFloat + it('preloads stylesheets without a precedence prop when server rendering', async () => { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + <html> + <head /> + <body> + <link rel="stylesheet" href="notaresource" /> + <div>hello world</div> + </body> + </html>, + ); + pipe(writable); + }); + + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link rel="preload" as="style" href="notaresource" /> + </head> + <body> + <link rel="stylesheet" href="notaresource" /> + <div>hello world</div> + </body> + </html>, + ); + }); + + // @gate enableFloat + it('hoists style resources to the correct precedence', async () => { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + <html> + <head /> + <body> + <link rel="stylesheet" href="foo1" precedence="foo" /> + <link rel="stylesheet" href="default1" precedence="default" /> + <link rel="stylesheet" href="foo2" precedence="foo" /> + <div>hello world</div> + </body> + </html>, + ); + pipe(writable); + }); + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link rel="stylesheet" href="foo1" data-rprec="foo" /> + <link rel="stylesheet" href="foo2" data-rprec="foo" /> + <link rel="stylesheet" href="default1" data-rprec="default" /> + </head> + <body> + <div>hello world</div> + </body> + </html>, + ); + + ReactDOMClient.hydrateRoot( + document, + <html> + <head /> + <body> + <link rel="stylesheet" href="bar1" precedence="bar" /> + <link rel="stylesheet" href="foo3" precedence="foo" /> + <link rel="stylesheet" href="default2" precedence="default" /> + <div>hello world</div> + </body> + </html>, + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link rel="stylesheet" href="foo1" data-rprec="foo" /> + <link rel="stylesheet" href="foo2" data-rprec="foo" /> + <link rel="stylesheet" href="foo3" data-rprec="foo" /> + <link rel="stylesheet" href="default1" data-rprec="default" /> + <link rel="stylesheet" href="default2" data-rprec="default" /> + <link rel="stylesheet" href="bar1" data-rprec="bar" /> + <link rel="preload" as="style" href="bar1" /> + <link rel="preload" as="style" href="foo3" /> + <link rel="preload" as="style" href="default2" /> + </head> + <body> + <div>hello world</div> + </body> + </html>, + ); + }); + + // @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( + <html> + <head /> + <body> + hello world + <link rel="stylesheet" href="foo" precedence="foo" /> + </body> + </html>, + ); + expect(Scheduler).toFlushWithoutYielding(); + + root.render( + <html> + <head /> + <body>hello world</body> + </html>, + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link rel="stylesheet" href="foo" data-rprec="foo" /> + </head> + <body>hello world</body> + </html>, + ); + }); + + // @gate enableFloat + it('retains styles even when a new html, head, and/body mount', async () => { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + <html> + <head /> + <body> + <link rel="stylesheet" href="foo" precedence="foo" /> + <link rel="stylesheet" href="bar" precedence="bar" /> + server + </body> + </html>, + ); + pipe(writable); + }); + const errors = []; + ReactDOMClient.hydrateRoot( + document, + <html> + <head> + <link rel="stylesheet" href="qux" precedence="qux" /> + <link rel="stylesheet" href="foo" precedence="foo" /> + </head> + <body>client</body> + </html>, + { + 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( + <html> + <head> + <link rel="stylesheet" href="qux" data-rprec="qux" /> + <link rel="stylesheet" href="foo" data-rprec="foo" /> + </head> + <body>client</body> + </html>, + ); + }); + + // Disabling for now since we are going to live with not having a restore step while we consider + // HostSingletons or other solutions + // @gate enableFloat + xit('retains styles in head through head remounts', async () => { + const root = ReactDOMClient.createRoot(document); + root.render( + <html> + <head key={1} /> + <body> + <link rel="stylesheet" href="foo" precedence="foo" /> + <link rel="stylesheet" href="bar" precedence="bar" /> + hello + </body> + </html>, + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link rel="stylesheet" href="foo" data-rprec="foo" /> + <link rel="stylesheet" href="bar" data-rprec="bar" /> + </head> + <body>hello</body> + </html>, + ); + + root.render( + <html> + <head key={2} /> + <body> + <link rel="stylesheet" href="foo" precedence="foo" /> + <link rel="stylesheet" href="bar" precedence="bar" /> + hello + </body> + </html>, + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link rel="stylesheet" href="foo" data-rprec="foo" /> + <link rel="stylesheet" href="bar" data-rprec="bar" /> + </head> + <body>hello</body> + </html>, + ); + }); + }); + + // @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 ( + <html> + <head /> + <body> + <Suspense fallback="loading..."> + <BlockedOn text="unblock"> + <link rel="stylesheet" href="foo" precedence="arbitrary" /> + <link rel="stylesheet" href="bar" precedence="arbitrary" /> + Hello + </BlockedOn> + </Suspense> + </body> + </html> + ); + } + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />); + pipe(writable); + }); + + await act(() => { + resolveText('unblock'); + }); + + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link rel="stylesheet" href="foo" data-rprec="arbitrary" /> + <link rel="stylesheet" href="bar" data-rprec="arbitrary" /> + </head> + <body> + loading... + <link rel="preload" href="foo" as="style" /> + <link rel="preload" href="bar" as="style" /> + </body> + </html>, + ); + + 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( + <html> + <head> + <link rel="stylesheet" href="foo" data-rprec="arbitrary" /> + <link rel="stylesheet" href="bar" data-rprec="arbitrary" /> + </head> + <body> + loading... + <link rel="preload" href="foo" as="style" /> + <link rel="preload" href="bar" as="style" /> + </body> + </html>, + ); + + const errors = []; + ReactDOMClient.hydrateRoot(document, <App />, { + onRecoverableError(err, errInfo) { + errors.push(err.message); + errors.push(err.digest); + }, + }); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link rel="stylesheet" href="foo" data-rprec="arbitrary" /> + <link rel="stylesheet" href="bar" data-rprec="arbitrary" /> + </head> + <body> + <link rel="preload" href="foo" as="style" /> + <link rel="preload" href="bar" as="style" /> + Hello + </body> + </html>, + ); + 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( + <html> + <head /> + <body> + <link rel="stylesheet" href="foo" precedence="arbitrary" /> + Hello + </body> + </html>, + ); + pipe(writable); + }); + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link rel="stylesheet" href="foo" data-rprec="arbitrary" /> + </head> + <body>Hello</body> + </html>, + ); + + ReactDOMClient.hydrateRoot( + document, + <html> + <head /> + <body>Hello</body> + </html>, + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link rel="stylesheet" href="foo" data-rprec="arbitrary" /> + </head> + <body>Hello</body> + </html>, + ); + }); + + // @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} + <link rel="stylesheet" href={href} precedence={precedence} /> + </> + ); + } + + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + <html> + <head /> + <body> + <AsyncTextWithResource text="foo" href="foo" precedence="one" /> + <AsyncTextWithResource text="bar" href="bar" precedence="two" /> + <AsyncTextWithResource text="baz" href="baz" precedence="three" /> + </body> + </html>, + ); + pipe(writable); + resolveText('foo'); + resolveText('bar'); + resolveText('baz'); + }); + + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link rel="stylesheet" href="foo" data-rprec="one" /> + <link rel="stylesheet" href="bar" data-rprec="two" /> + <link rel="stylesheet" href="baz" data-rprec="three" /> + </head> + <body> + {'foo'} + {'bar'} + {'baz'} + </body> + </html>, + ); + }); + + // @gate enableFloat + it('hoists late stylesheets the correct precedence', async () => { + function AsyncListItemWithResource({text, href, precedence, ...rest}) { + const value = readText(text); + return ( + <li> + <link + rel="stylesheet" + href={href} + precedence={precedence} + {...rest} + /> + {value} + </li> + ); + } + function BlockingChildren({text, children}) { + readText(text); + return children; + } + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + <html> + <head /> + <body> + <link rel="stylesheet" href="initial" precedence="one" /> + <div> + <Suspense fallback="loading foo bar..."> + <link rel="stylesheet" href="foo" precedence="one" /> + <ul> + <li> + <AsyncText text="foo" /> + </li> + <AsyncListItemWithResource + text="bar" + href="bar" + precedence="default" + data-foo="foo" + crossOrigin="anonymous" + /> + </ul> + </Suspense> + </div> + <div> + <Suspense fallback="loading bar baz qux..."> + <ul> + <AsyncListItemWithResource + text="bar" + href="bar" + precedence="default" + /> + <AsyncListItemWithResource + text="baz" + href="baz" + precedence="two" + /> + <AsyncListItemWithResource + text="qux" + href="qux" + precedence="one" + /> + </ul> + </Suspense> + </div> + <div> + <Suspense fallback="loading bar baz qux..."> + <BlockingChildren text="unblock"> + <ul> + <AsyncListItemWithResource + text="bar" + href="bar" + precedence="default" + /> + <AsyncListItemWithResource + text="baz" + href="baz" + precedence="two" + /> + <AsyncListItemWithResource + text="qux" + href="qux" + precedence="one" + /> + </ul> + </BlockingChildren> + </Suspense> + </div> + </body> + </html>, + ); + pipe(writable); + }); + + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link rel="stylesheet" href="initial" data-rprec="one" /> + <link rel="preload" href="foo" as="style" /> + </head> + <body> + <div>loading foo bar...</div> + <div>loading bar baz qux...</div> + <div>loading bar baz qux...</div> + </body> + </html>, + ); + + await act(() => { + resolveText('foo'); + resolveText('bar'); + }); + + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link rel="stylesheet" href="initial" data-rprec="one" /> + <link rel="stylesheet" href="foo" data-rprec="one" /> + <link + rel="stylesheet" + href="bar" + data-rprec="default" + data-foo="foo" + crossorigin="anonymous" + /> + <link rel="preload" href="foo" as="style" /> + </head> + <body> + <div>loading foo bar...</div> + <div>loading bar baz qux...</div> + <div>loading bar baz qux...</div> + <link rel="preload" href="bar" as="style" crossorigin="anonymous" /> + </body> + </html>, + ); + + 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( + <html> + <head> + <link rel="stylesheet" href="initial" data-rprec="one" /> + <link rel="stylesheet" href="foo" data-rprec="one" /> + <link + rel="stylesheet" + href="bar" + data-rprec="default" + data-foo="foo" + crossorigin="anonymous" + /> + <link rel="preload" href="foo" as="style" /> + </head> + <body> + <div>loading foo bar...</div> + <div>loading bar baz qux...</div> + <div>loading bar baz qux...</div> + <link rel="preload" href="bar" as="style" crossorigin="anonymous" /> + </body> + </html>, + ); + + 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( + <html> + <head> + <link rel="stylesheet" href="initial" data-rprec="one" /> + <link rel="stylesheet" href="foo" data-rprec="one" /> + <link + rel="stylesheet" + href="bar" + data-rprec="default" + data-foo="foo" + crossorigin="anonymous" + /> + <link rel="preload" href="foo" as="style" /> + </head> + <body> + <div> + <ul> + <li>foo</li> + <li>bar</li> + </ul> + </div> + <div>loading bar baz qux...</div> + <div>loading bar baz qux...</div> + <link rel="preload" href="bar" as="style" crossorigin="anonymous" /> + </body> + </html>, + ); + + await act(() => { + resolveText('baz'); + }); + + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link rel="stylesheet" href="initial" data-rprec="one" /> + <link rel="stylesheet" href="foo" data-rprec="one" /> + <link + rel="stylesheet" + href="bar" + data-rprec="default" + data-foo="foo" + crossorigin="anonymous" + /> + <link rel="preload" href="foo" as="style" /> + </head> + <body> + <div> + <ul> + <li>foo</li> + <li>bar</li> + </ul> + </div> + <div>loading bar baz qux...</div> + <div>loading bar baz qux...</div> + <link rel="preload" as="style" href="bar" crossorigin="anonymous" /> + <link rel="preload" as="style" href="baz" /> + </body> + </html>, + ); + + await act(() => { + resolveText('qux'); + }); + + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link rel="stylesheet" href="initial" data-rprec="one" /> + <link rel="stylesheet" href="foo" data-rprec="one" /> + <link rel="stylesheet" href="qux" data-rprec="one" /> + <link + rel="stylesheet" + href="bar" + data-rprec="default" + data-foo="foo" + crossorigin="anonymous" + /> + <link rel="stylesheet" href="baz" data-rprec="two" /> + <link rel="preload" href="foo" as="style" /> + </head> + <body> + <div> + <ul> + <li>foo</li> + <li>bar</li> + </ul> + </div> + <div>loading bar baz qux...</div> + <div>loading bar baz qux...</div> + <link rel="preload" as="style" href="bar" crossorigin="anonymous" /> + <link rel="preload" as="style" href="baz" /> + <link rel="preload" as="style" href="qux" /> + </body> + </html>, + ); + + 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( + <html> + <head> + <link rel="stylesheet" href="initial" data-rprec="one" /> + <link rel="stylesheet" href="foo" data-rprec="one" /> + <link rel="stylesheet" href="qux" data-rprec="one" /> + <link + rel="stylesheet" + href="bar" + data-rprec="default" + data-foo="foo" + crossorigin="anonymous" + /> + <link rel="stylesheet" href="baz" data-rprec="two" /> + <link rel="preload" href="foo" as="style" /> + </head> + <body> + <div> + <ul> + <li>foo</li> + <li>bar</li> + </ul> + </div> + <div> + <ul> + <li>bar</li> + <li>baz</li> + <li>qux</li> + </ul> + </div> + <div>loading bar baz qux...</div> + <link rel="preload" as="style" href="bar" crossorigin="anonymous" /> + <link rel="preload" as="style" href="baz" /> + <link rel="preload" as="style" href="qux" /> + </body> + </html>, + ); + + await act(() => { + resolveText('unblock'); + }); + + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link rel="stylesheet" href="initial" data-rprec="one" /> + <link rel="stylesheet" href="foo" data-rprec="one" /> + <link rel="stylesheet" href="qux" data-rprec="one" /> + <link + rel="stylesheet" + href="bar" + data-rprec="default" + data-foo="foo" + crossorigin="anonymous" + /> + <link rel="stylesheet" href="baz" data-rprec="two" /> + <link rel="preload" href="foo" as="style" /> + </head> + <body> + <div> + <ul> + <li>foo</li> + <li>bar</li> + </ul> + </div> + <div> + <ul> + <li>bar</li> + <li>baz</li> + <li>qux</li> + </ul> + </div> + <div> + <ul> + <li>bar</li> + <li>baz</li> + <li>qux</li> + </ul> + </div> + <link rel="preload" as="style" href="bar" crossorigin="anonymous" /> + <link rel="preload" as="style" href="baz" /> + <link rel="preload" as="style" href="qux" /> + </body> + </html>, + ); + }); + + // @gate enableFloat + it('normalizes style resource precedence for all boundaries inlined as part of the shell flush', async () => { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + <html> + <head /> + <body> + <div> + outer + <link rel="stylesheet" href="1one" precedence="one" /> + <link rel="stylesheet" href="1two" precedence="two" /> + <link rel="stylesheet" href="1three" precedence="three" /> + <link rel="stylesheet" href="1four" precedence="four" /> + <Suspense fallback={null}> + <div> + middle + <link rel="stylesheet" href="2one" precedence="one" /> + <link rel="stylesheet" href="2two" precedence="two" /> + <link rel="stylesheet" href="2three" precedence="three" /> + <link rel="stylesheet" href="2four" precedence="four" /> + <Suspense fallback={null}> + <div> + inner + <link rel="stylesheet" href="3five" precedence="five" /> + <link rel="stylesheet" href="3one" precedence="one" /> + <link rel="stylesheet" href="3two" precedence="two" /> + <link rel="stylesheet" href="3three" precedence="three" /> + <link rel="stylesheet" href="3four" precedence="four" /> + </div> + </Suspense> + </div> + </Suspense> + <Suspense fallback={null}> + <div>middle</div> + <link rel="stylesheet" href="4one" precedence="one" /> + <link rel="stylesheet" href="4two" precedence="two" /> + <link rel="stylesheet" href="4three" precedence="three" /> + <link rel="stylesheet" href="4four" precedence="four" /> + </Suspense> + </div> + </body> + </html>, + ); + 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( + <html> + <head> + <link rel="stylesheet" href="1one" data-rprec="one" /> + <link rel="stylesheet" href="3one" data-rprec="one" /> + <link rel="stylesheet" href="2one" data-rprec="one" /> + <link rel="stylesheet" href="4one" data-rprec="one" /> + + <link rel="stylesheet" href="1two" data-rprec="two" /> + <link rel="stylesheet" href="3two" data-rprec="two" /> + <link rel="stylesheet" href="2two" data-rprec="two" /> + <link rel="stylesheet" href="4two" data-rprec="two" /> + + <link rel="stylesheet" href="1three" data-rprec="three" /> + <link rel="stylesheet" href="3three" data-rprec="three" /> + <link rel="stylesheet" href="2three" data-rprec="three" /> + <link rel="stylesheet" href="4three" data-rprec="three" /> + + <link rel="stylesheet" href="1four" data-rprec="four" /> + <link rel="stylesheet" href="3four" data-rprec="four" /> + <link rel="stylesheet" href="2four" data-rprec="four" /> + <link rel="stylesheet" href="4four" data-rprec="four" /> + + <link rel="stylesheet" href="3five" data-rprec="five" /> + </head> + <body> + <div> + outer + <div> + middle<div>inner</div> + </div> + <div>middle</div> + </div> + </body> + </html>, + ); + }); + + // @gate enableFloat + it('style resources are inserted according to precedence order on the client', async () => { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + <html> + <head /> + <body> + <div> + <link rel="stylesheet" href="foo" precedence="one" /> + <link rel="stylesheet" href="bar" precedence="two" /> + Hello + </div> + </body> + </html>, + ); + pipe(writable); + }); + + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link rel="stylesheet" href="foo" data-rprec="one" /> + <link rel="stylesheet" href="bar" data-rprec="two" /> + </head> + <body> + <div>Hello</div> + </body> + </html>, + ); + + const root = ReactDOMClient.hydrateRoot( + document, + <html> + <head /> + <body> + <div> + <link rel="stylesheet" href="foo" precedence="one" /> + <link rel="stylesheet" href="bar" precedence="two" /> + Hello + </div> + </body> + </html>, + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link rel="stylesheet" href="foo" data-rprec="one" /> + <link rel="stylesheet" href="bar" data-rprec="two" /> + </head> + <body> + <div>Hello</div> + </body> + </html>, + ); + + root.render( + <html> + <head /> + <body> + <div>Hello</div> + <link rel="stylesheet" href="baz" precedence="one" /> + </body> + </html>, + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link rel="stylesheet" href="foo" data-rprec="one" /> + <link rel="stylesheet" href="baz" data-rprec="one" /> + <link rel="stylesheet" href="bar" data-rprec="two" /> + <link rel="preload" as="style" href="baz" /> + </head> + <body> + <div>Hello</div> + </body> + </html>, + ); + }); + + // @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( + <ErrorBoundary> + <link rel="stylesheet" href="foo" precedence="default" /> + <div>foo</div> + <Throw /> + </ErrorBoundary>, + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link rel="preload" href="foo" as="style" /> + </head> + <body> + <div id="container">Uh oh!</div> + </body> + </html>, + ); + }); + + // @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( + <html> + <head /> + <body> + <div> + <link rel="stylesheet" href="foo" precedence="one" /> + <link rel="stylesheet" href="bar" precedence="two" /> + Hello + </div> + <div> + <Suspense fallback={'loading...'}> + <PreinitsBlockedOn text="foo" /> + <AsyncText text="bar" /> + </Suspense> + </div> + </body> + </html>, + ); + pipe(writable); + }); + + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link rel="stylesheet" href="foo" data-rprec="one" /> + <link rel="stylesheet" href="bar" data-rprec="two" /> + </head> + <body> + <div>Hello</div> + <div>loading...</div> + </body> + </html>, + ); + + await act(() => { + resolveText('foo'); + }); + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link rel="stylesheet" href="foo" data-rprec="one" /> + <link rel="stylesheet" href="bar" data-rprec="two" /> + </head> + <body> + <div>Hello</div> + <div>loading...</div> + <link rel="preload" href="one" as="style" /> + <link rel="preload" href="two" as="style" /> + </body> + </html>, + ); + + await act(() => { + resolveText('bar'); + }); + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link rel="stylesheet" href="foo" data-rprec="one" /> + <link rel="stylesheet" href="bar" data-rprec="two" /> + </head> + <body> + <div>Hello</div> + <div>bar</div> + <link rel="preload" href="one" as="style" /> + <link rel="preload" href="two" as="style" /> + </body> + </html>, + ); + }); + + // @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( + <html> + <head /> + <body> + <div> + <Suspense fallback="loading foo..."> + <BlockedOn text="foo"> + <div>foo</div> + <link rel="stylesheet" href="foo" precedence="default" /> + <Suspense fallback="loading bar..."> + <BlockedOn text="bar"> + <div>bar</div> + <link rel="stylesheet" href="bar" precedence="default" /> + <Suspense fallback="loading baz..."> + <BlockedOn text="baz"> + <div>baz</div> + <link + rel="stylesheet" + href="baz" + precedence="default" + /> + </BlockedOn> + </Suspense> + </BlockedOn> + </Suspense> + </BlockedOn> + </Suspense> + </div> + </body> + </html>, + ); + pipe(writable); + }); + + expect(getVisibleChildren(document)).toEqual( + <html> + <head /> + <body> + <div>loading foo...</div> + </body> + </html>, + ); + + await act(() => { + resolveText('bar'); + }); + expect(getVisibleChildren(document)).toEqual( + <html> + <head /> + <body> + <div>loading foo...</div> + </body> + </html>, + ); + + await act(() => { + resolveText('baz'); + }); + expect(getVisibleChildren(document)).toEqual( + <html> + <head /> + <body> + <div>loading foo...</div> + </body> + </html>, + ); + + await act(() => { + resolveText('foo'); + }); + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link rel="stylesheet" href="foo" data-rprec="default" /> + <link rel="stylesheet" href="bar" data-rprec="default" /> + <link rel="stylesheet" href="baz" data-rprec="default" /> + </head> + <body> + <div>loading foo...</div> + <link rel="preload" href="foo" as="style" /> + <link rel="preload" href="bar" as="style" /> + <link rel="preload" href="baz" as="style" /> + </body> + </html>, + ); + + 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( + <html> + <head> + <link rel="stylesheet" href="foo" data-rprec="default" /> + <link rel="stylesheet" href="bar" data-rprec="default" /> + <link rel="stylesheet" href="baz" data-rprec="default" /> + </head> + <body> + <div> + <div>foo</div> + <div>bar</div> + <div>baz</div> + </div> + <link rel="preload" href="foo" as="style" /> + <link rel="preload" href="bar" as="style" /> + <link rel="preload" href="baz" as="style" /> + </body> + </html>, + ); + }); + + // @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( + <html> + <head /> + <body> + <div> + <Suspense fallback="loading..."> + <div> + <BlockedOn text="foo"> + <div>foo</div> + <link rel="stylesheet" href="foo" precedence="default" /> + <Suspense fallback="loading bar..."> + <BlockedOn text="bar"> + <div>bar</div> + <link + rel="stylesheet" + href="bar" + precedence="default" + /> + <Suspense fallback="loading baz..."> + <div> + <BlockedOn text="baz"> + <div>baz</div> + <link + rel="stylesheet" + href="baz" + precedence="default" + /> + </BlockedOn> + </div> + </Suspense> + </BlockedOn> + </Suspense> + </BlockedOn> + <BlockedOn text="qux"> + <div>qux</div> + <link rel="stylesheet" href="qux" precedence="default" /> + </BlockedOn> + </div> + </Suspense> + </div> + </body> + </html>, + ); + pipe(writable); + }); + + expect(getVisibleChildren(document)).toEqual( + <html> + <head /> + <body> + <div>loading...</div> + </body> + </html>, + ); + + // This will enqueue a style resource in a deep blocked boundary (loading baz...). + await act(() => { + resolveText('baz'); + }); + expect(getVisibleChildren(document)).toEqual( + <html> + <head /> + <body> + <div>loading...</div> + </body> + </html>, + ); + + // This will enqueue a style resource in the intermediate blocked boundary (loading bar...). + await act(() => { + resolveText('bar'); + }); + expect(getVisibleChildren(document)).toEqual( + <html> + <head /> + <body> + <div>loading...</div> + </body> + </html>, + ); + + // 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( + <html> + <head /> + <body> + <div>loading...</div> + <link rel="preload" href="foo" as="style" /> + <link rel="preload" href="bar" as="style" /> + <link rel="preload" href="baz" as="style" /> + </body> + </html>, + ); + + // 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( + <html> + <head> + <link rel="stylesheet" href="foo" data-rprec="default" /> + <link rel="stylesheet" href="bar" data-rprec="default" /> + <link rel="stylesheet" href="baz" data-rprec="default" /> + <link rel="stylesheet" href="qux" data-rprec="default" /> + </head> + <body> + <div>loading...</div> + <link rel="preload" href="foo" as="style" /> + <link rel="preload" href="bar" as="style" /> + <link rel="preload" href="baz" as="style" /> + <link rel="preload" href="qux" as="style" /> + </body> + </html>, + ); + + // 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( + <html> + <head> + <link rel="stylesheet" href="foo" data-rprec="default" /> + <link rel="stylesheet" href="bar" data-rprec="default" /> + <link rel="stylesheet" href="baz" data-rprec="default" /> + <link rel="stylesheet" href="qux" data-rprec="default" /> + </head> + <body> + <div> + <div> + <div>foo</div> + <div>bar</div> + <div> + <div>baz</div> + </div> + <div>qux</div> + </div> + </div> + <link rel="preload" href="foo" as="style" /> + <link rel="preload" href="bar" as="style" /> + <link rel="preload" href="baz" as="style" /> + <link rel="preload" href="qux" as="style" /> + </body> + </html>, + ); + }); + + // @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 ( + <html> + <head /> + <body> + <div> + <link + // This preload is explicit so it can flush with a lot of potential attrs + // We will duplicate this as a style that flushes after the shell + rel="preload" + as="style" + href="foo" + // precedence is not a special attribute for preloads so this will just flush as is + precedence="default" + // Some standard link props + crossOrigin="anonymous" + media="all" + integrity="somehash" + referrerPolicy="origin" + // data and non starndard attributes that should flush + data-foo={'"quoted"'} + nonStandardAttr="attr" + properlyformattednonstandardattr="attr" + // attributes that should be filtered out for violating certain rules + onSomething="this should be removed b/c event handler" + shouldnotincludefunctions={() => {}} + norsymbols={Symbol('foo')} + /> + <Suspense fallback={'loading...'}> + <BlockedOn text="unblock"> + <link + // This preload is explicit so it can flush with a lot of potential attrs + // We will duplicate this as a style that flushes after the shell + rel="stylesheet" + href="foo" + // opt-in property to get this treated as a resource + precedence="default" + // Some standard link props + crossOrigin="anonymous" + media="all" + integrity="somehash" + referrerPolicy="origin" + // data and non starndard attributes that should flush + data-foo={'"quoted"'} + nonStandardAttr="attr" + properlyformattednonstandardattr="attr" + // attributes that should be filtered out for violating certain rules + onSomething="this should be removed b/c event handler" + shouldnotincludefunctions={() => {}} + norsymbols={Symbol('foo')} + /> + </BlockedOn> + </Suspense> + </div> + </body> + </html> + ); + } + try { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />); + pipe(writable); + }); + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link + rel="preload" + as="style" + href="foo" + precedence="default" + crossorigin="anonymous" + media="all" + integrity="somehash" + referrerpolicy="origin" + data-foo={'"quoted"'} + nonstandardattr="attr" + properlyformattednonstandardattr="attr" + /> + </head> + <body> + <div>loading...</div> + </body> + </html>, + ); + 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( + <html> + <head> + <link + rel="stylesheet" + href="foo" + data-rprec="default" + crossorigin="anonymous" + media="all" + integrity="somehash" + referrerpolicy="origin" + data-foo={'"quoted"'} + nonstandardattr="attr" + properlyformattednonstandardattr="attr" + /> + <link + rel="preload" + as="style" + href="foo" + precedence="default" + crossorigin="anonymous" + media="all" + integrity="somehash" + referrerpolicy="origin" + data-foo={'"quoted"'} + nonstandardattr="attr" + properlyformattednonstandardattr="attr" + /> + </head> + <body> + <div>loading...</div> + </body> + </html>, + ); + 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( + <html> + <head /> + <body> + <div> + <Suspense fallback="loading A..."> + <BlockedOn text="unblock"> + <AsyncText text="A" /> + <link rel="stylesheet" href="A" precedence="A" /> + <Suspense fallback="loading AA..."> + <AsyncText text="AA" /> + <link rel="stylesheet" href="AA" precedence="AA" /> + <Suspense fallback="loading AAA..."> + <AsyncText text="AAA" /> + <link rel="stylesheet" href="AAA" precedence="AAA" /> + <Suspense fallback="loading AAAA..."> + <AsyncText text="AAAA" /> + <link rel="stylesheet" href="AAAA" precedence="AAAA" /> + </Suspense> + </Suspense> + </Suspense> + </BlockedOn> + </Suspense> + </div> + </body> + </html>, + ); + pipe(writable); + }); + expect(getVisibleChildren(document)).toEqual( + <html> + <head /> + <body> + <div>loading A...</div> + </body> + </html>, + ); + + await act(() => { + resolveText('unblock'); + resolveText('AAAA'); + resolveText('AA'); + }); + expect(getVisibleChildren(document)).toEqual( + <html> + <head /> + <body> + <div>loading A...</div> + <link rel="preload" as="style" href="A" /> + <link rel="preload" as="style" href="AA" /> + <link rel="preload" as="style" href="AAA" /> + <link rel="preload" as="style" href="AAAA" /> + </body> + </html>, + ); + + 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( + <html> + <head> + <link rel="stylesheet" href="A" data-rprec="A" /> + <link rel="stylesheet" href="AA" data-rprec="AA" /> + </head> + <body> + <div> + {'A'} + {'AA'} + {'loading AAA...'} + </div> + <link rel="preload" as="style" href="A" /> + <link rel="preload" as="style" href="AA" /> + <link rel="preload" as="style" href="AAA" /> + <link rel="preload" as="style" href="AAAA" /> + </body> + </html>, + ); + + 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( + <html> + <head> + <link rel="stylesheet" href="A" data-rprec="A" /> + <link rel="stylesheet" href="AA" data-rprec="AA" /> + <link rel="stylesheet" href="AAA" data-rprec="AAA" /> + <link rel="stylesheet" href="AAAA" data-rprec="AAAA" /> + </head> + <body> + <div> + {'A'} + {'AA'} + {'AAA'} + {'AAAA'} + </div> + <link rel="preload" as="style" href="A" /> + <link rel="preload" as="style" href="AA" /> + <link rel="preload" as="style" href="AAA" /> + <link rel="preload" as="style" href="AAAA" /> + </body> + </html>, + ); + }); + + // @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( + <html> + <head /> + <body> + <link rel="preload" as="font" href="foo" /> + <link rel="preload" as="font" href="bar" crossOrigin="foo" /> + <link + rel="preload" + as="font" + href="baz" + crossOrigin="use-credentials" + /> + <link + rel="preload" + as="font" + href="qux" + crossOrigin="anonymous" + /> + </body> + </html>, + ); + pipe(writable); + }); + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link rel="preload" as="font" href="foo" crossorigin="" /> + <link rel="preload" as="font" href="bar" crossorigin="" /> + <link rel="preload" as="font" href="baz" crossorigin="" /> + <link rel="preload" as="font" href="qux" crossorigin="" /> + </head> + <body /> + </html>, + ); + + 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( + <html> + <head> + <Component scenarios={scenarios} /> + </head> + </html>, + ); + 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( + <html> + <head> + <Component scenarios={scenarios} /> + </head> + </html>, + ); + 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( + <div> + <link + rel="stylesheet" + href="foo" + precedence="foo" + data-something-extra="extra" + /> + <link + rel="stylesheet" + href="bar" + precedence="bar" + data-something-extra="extra" + /> + hello + </div>, + ); + expect(Scheduler).toFlushWithoutYielding(); + + root.render( + <div> + <link + rel="stylesheet" + href="foo" + precedence="fu" + data-something-new="new" + /> + <link + rel="stylesheet" + href="baz" + precedence="baz" + data-something-new="new" + /> + hello + </div>, + ); + 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( + <html> + <head> + <link + rel="stylesheet" + href="foo" + data-rprec="foo" + data-something-extra="extra" + /> + <link + rel="stylesheet" + href="bar" + data-rprec="bar" + data-something-extra="extra" + /> + <link + rel="stylesheet" + href="baz" + data-rprec="baz" + data-something-new="new" + /> + <link rel="preload" as="style" href="foo" /> + <link rel="preload" as="style" href="bar" /> + <link rel="preload" as="style" href="baz" /> + </head> + <body> + <div id="container"> + <div>hello</div> + </div> + </body> + </html>, + ); + }); + + // @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( + <html> + <head> + <link + rel="stylesheet" + href="foo" + precedence="foo" + media="all" + /> + <link rel="stylesheet" href="foo" precedence="foo" /> + + <link rel="stylesheet" href="bar" precedence="bar" /> + <link + rel="stylesheet" + href="bar" + precedence="bar" + media="all" + /> + + <link + rel="stylesheet" + href="baz" + precedence="baz" + media="some" + /> + <link + rel="stylesheet" + href="baz" + precedence="baz" + media="all" + /> + </head> + </html>, + ); + pipe(writable); + }); + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link rel="stylesheet" href="foo" data-rprec="foo" media="all" /> + <link rel="stylesheet" href="bar" data-rprec="bar" /> + + <link rel="stylesheet" href="baz" data-rprec="baz" media="some" /> + </head> + <body /> + </html>, + ); + + 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( + <html> + <head> + <link + rel="stylesheet" + href="foo" + precedence="foo" + data-foo="an original value" + /> + <link rel="stylesheet" href="foo" precedence="foo" /> + <link rel="stylesheet" href="foo" precedence="foonew" /> + + <link rel="stylesheet" href="bar" precedence="bar" /> + <link + rel="stylesheet" + href="bar" + precedence="bar" + data-foo="a new value" + /> + + <link + rel="stylesheet" + href="baz" + precedence="baz" + data-foo="an original value" + /> + <link + rel="stylesheet" + href="baz" + precedence="baz" + data-foo="a new value" + /> + </head> + </html>, + ); + pipe(writable); + }); + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link + rel="stylesheet" + href="foo" + data-rprec="foo" + data-foo="an original value" + /> + <link rel="stylesheet" href="bar" data-rprec="bar" /> + <link + rel="stylesheet" + href="baz" + data-rprec="baz" + data-foo="an original value" + /> + </head> + <body /> + </html>, + ); + + 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( + <html> + <head> + <link + rel="stylesheet" + href="foo" + precedence="foo" + onLoad={() => {}} + onError={() => {}} + /> + <link + rel="stylesheet" + href="bar" + precedence="bar" + onLoad={() => {}} + /> + <link + rel="stylesheet" + href="baz" + precedence="baz" + onError={() => {}} + /> + <link + rel="stylesheet" + href="qux" + precedence="qux" + disabled={true} + /> + </head> + <body /> + </html>, + ); + 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( + <html> + <head> + <link rel="preload" as="style" href="foo" /> + <link rel="preload" as="style" href="bar" /> + <link rel="preload" as="style" href="baz" /> + <link rel="preload" as="style" href="qux" /> + <link rel="stylesheet" href="foo" /> + <link rel="stylesheet" href="bar" /> + <link rel="stylesheet" href="baz" /> + <link rel="stylesheet" href="qux" disabled="" /> + </head> + <body /> + </html>, + ); + + 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( + <html> + <head> + <link + rel="preload" + as="style" + href="foo" + data-foo="a current value" + /> + <link + rel="preload" + as="style" + href="foo" + data-foo="a new value" + /> + + <link + rel="preload" + as="style" + href="bar" + data-bar="a current value" + /> + <link + rel="preload" + as="font" + href="bar" + data-bar="a current value" + crossOrigin="" + /> + </head> + </html>, + ); + pipe(writable); + }); + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link + rel="preload" + as="style" + href="foo" + data-foo="a current value" + /> + <link + rel="preload" + as="style" + href="bar" + data-bar="a current value" + /> + </head> + <body /> + </html>, + ); + + 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( + <html> + <head> + <link + rel="preload" + as="style" + href="foo" + crossOrigin="preload value" + /> + <link + rel="stylesheet" + href="foo" + precedence="foo" + crossOrigin="style value" + /> + </head> + </html>, + ); + pipe(writable); + }); + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link + rel="stylesheet" + href="foo" + data-rprec="foo" + crossorigin="style value" + /> + </head> + <body /> + </html>, + ); + + 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; + } + }); + }); + + describe('escaping', () => { + // @gate enableFloat + it('escapes hrefs when selecting matching elements in the document when rendering Resources', async () => { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + <html> + <head /> + <body> + <link rel="preload" href="preload" as="style" /> + <link rel="stylesheet" href="style" precedence="style" /> + <link rel="stylesheet" href="with\slashes" precedence="style" /> + <link rel="preload" href={'with\nnewline'} as="style" /> + <div id="container" /> + </body> + </html>, + ); + pipe(writable); + }); + + container = document.getElementById('container'); + const root = ReactDOMClient.createRoot(container); + root.render( + <div> + <link rel="preload" href={'preload"][rel="preload'} as="style" /> + <link + rel="stylesheet" + href={'style"][rel="stylesheet'} + precedence="style" + /> + <link rel="stylesheet" href={'with\\slashes'} precedence="style" /> + <link rel="preload" href={'with\nnewline'} as="style" /> + foo + </div>, + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link rel="preload" as="style" href="preload" /> + <link rel="preload" href={'with\nnewline'} as="style" /> + <link rel="stylesheet" href="style" data-rprec="style" /> + <link rel="stylesheet" href="with\slashes" data-rprec="style" /> + <link + rel="stylesheet" + href={'style"][rel="stylesheet'} + data-rprec="style" + /> + <link rel="preload" href={'preload"][rel="preload'} as="style" /> + <link rel="preload" href={'style"][rel="stylesheet'} as="style" /> + </head> + <body> + <div id="container"> + <div>foo</div> + </div> + </body> + </html>, + ); + }); + + // @gate enableFloat + it('escapes hrefs when selecting matching elements in the document when using preload and preinit', async () => { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + <html> + <head /> + <body> + <link rel="preload" href="preload" as="style" /> + <link rel="stylesheet" href="style" precedence="style" /> + <link rel="stylesheet" href="with\slashes" precedence="style" /> + <link rel="preload" href={'with\nnewline'} as="style" /> + <div id="container" /> + </body> + </html>, + ); + 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', + }); + ReactDOM.preload('with\nnewline', {as: 'style'}); + return <div>foo</div>; + } + + container = document.getElementById('container'); + const root = ReactDOMClient.createRoot(container); + root.render(<App />); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link rel="preload" as="style" href="preload" /> + <link rel="preload" href={'with\nnewline'} as="style" /> + <link rel="stylesheet" href="style" data-rprec="style" /> + <link rel="stylesheet" href="with\slashes" data-rprec="style" /> + <link + rel="stylesheet" + href={'style"][rel="stylesheet'} + data-rprec="style" + /> + <link rel="preload" href={'preload"][rel="preload'} as="style" /> + </head> + <body> + <div id="container"> + <div>foo</div> + </div> + </body> + </html>, + ); + }); + }); +}); diff --git a/packages/react-dom/src/client/ReactDOM.js b/packages/react-dom/src/client/ReactDOM.js index 3134f220aa..849bc283e0 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 cea9be01a3..9926346128 100644 --- a/packages/react-dom/src/client/ReactDOMRoot.js +++ b/packages/react-dom/src/client/ReactDOMRoot.js @@ -13,6 +13,9 @@ import type { TransitionTracingCallbacks, } 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'; import {enableFloat} from 'shared/ReactFeatureFlags'; @@ -235,6 +238,10 @@ 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) @@ -319,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); diff --git a/packages/react-dom/src/test-utils/ReactTestUtils.js b/packages/react-dom/src/test-utils/ReactTestUtils.js index 319a2cd589..22dbd6f4c0 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 3c66e0ad8c..d78339b812 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, @@ -615,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 c26c5c389c..5deae0672e 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, @@ -514,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-native-renderer/src/server/ReactNativeServerFormatConfig.js b/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js index 92685d4f85..4a7702e703 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(previousDispatcher: mixed) {} +export function createResources() {} +export function createBoundaryResources() {} +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 7e0a33b979..8afadf54d0 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<Segment | Instance | SuspenseInstance>, }; +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; + }, + + setCurrentlyRenderingBoundaryResourcesTarget(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 66919fc892..46464a37f8 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); }, + prepareRendererToRender() {}, + resetRendererAfterRender() {}, }; const hostConfig = useMutation diff --git a/packages/react-reconciler/src/ReactFiber.new.js b/packages/react-reconciler/src/ReactFiber.new.js index 3cff042b3f..18a3bf721b 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 f4717d6374..94e0050e8b 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 92eab56c0d..3ba2334894 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 84cc43148c..f654dce025 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 dfac5d034f..71937836b5 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 f75a7edda0..f54bfe22d9 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 592bef4fbd..eec23dfe83 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 f3ba7bf079..10be55575c 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 43ad9fd3b5..3821758c67 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 9eba0aad9d..f61c0eb778 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 0000000000..b620a06b37 --- /dev/null +++ b/packages/react-reconciler/src/ReactFiberHostConfigWithNoResources.js @@ -0,0 +1,26 @@ +/** + * 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 isHostResourceType = shim; +export const getResource = shim; +export const acquireResource = shim; +export const releaseResource = shim; diff --git a/packages/react-reconciler/src/ReactFiberHotReloading.new.js b/packages/react-reconciler/src/ReactFiberHotReloading.new.js index 4f289fe419..96eefa62f7 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 55f0f94cf8..5b08bdf35f 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 66c28274ae..82df8620e0 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 10e59b1d2b..3f6ade7832 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 4aa5a203ae..67600492d1 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 1de655dfb0..a5c408681d 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 218d41919e..870983968c 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 8f9e856772..c72de5c18d 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 { @@ -1767,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) { @@ -1781,6 +1784,7 @@ function pushDispatcher() { } function popDispatcher(prevDispatcher) { + resetRendererAfterRender(); ReactCurrentDispatcher.current = prevDispatcher; } @@ -1850,7 +1854,7 @@ export function renderHasNotSuspendedYet(): boolean { function renderRootSync(root: FiberRoot, lanes: Lanes) { const prevExecutionContext = executionContext; executionContext |= RenderContext; - const prevDispatcher = pushDispatcher(); + 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. @@ -1950,7 +1954,7 @@ function workLoopSync() { function renderRootConcurrent(root: FiberRoot, lanes: Lanes) { const prevExecutionContext = executionContext; executionContext |= RenderContext; - const prevDispatcher = pushDispatcher(); + 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. diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js index ef4f355909..45b8a60ada 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 { @@ -1767,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) { @@ -1781,6 +1784,7 @@ function pushDispatcher() { } function popDispatcher(prevDispatcher) { + resetRendererAfterRender(); ReactCurrentDispatcher.current = prevDispatcher; } @@ -1850,7 +1854,7 @@ export function renderHasNotSuspendedYet(): boolean { function renderRootSync(root: FiberRoot, lanes: Lanes) { const prevExecutionContext = executionContext; executionContext |= RenderContext; - const prevDispatcher = pushDispatcher(); + 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. @@ -1950,7 +1954,7 @@ function workLoopSync() { function renderRootConcurrent(root: FiberRoot, lanes: Lanes) { const prevExecutionContext = executionContext; executionContext |= RenderContext; - const prevDispatcher = pushDispatcher(); + 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. diff --git a/packages/react-reconciler/src/ReactTestSelectors.js b/packages/react-reconciler/src/ReactTestSelectors.js index 48640fd6c7..3ce6720c85 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<Selector>): Array<Fiber> { 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<Selector>): 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 00d2d93794..0a62312ce8 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 19ddc6c826..64912d57ef 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; }, + prepareRendererToRender: function() {}, + resetRendererAfterRender: function() {}, supportsMutation: true, requestPostPaintCallback: function() {}, }); @@ -131,6 +133,8 @@ describe('ReactFiberHostContext', () => { return DefaultEventPriority; }, requestPostPaintCallback: 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 517b45ead8..4bb920ca8b 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 @@ -187,6 +189,13 @@ 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 isHostResourceType = $$$hostConfig.isHostResourceType; +export const getResource = $$$hostConfig.getResource; +export const acquireResource = $$$hostConfig.acquireResource; +export const releaseResource = $$$hostConfig.releaseResource; diff --git a/packages/react-reconciler/src/getComponentNameFromFiber.js b/packages/react-reconciler/src/getComponentNameFromFiber.js index 883ac838b9..4b14f01014 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-dom-relay/package.json b/packages/react-server-dom-relay/package.json index 793363bb30..5fef49b1d1 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" } } diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 5f44458e78..ab9415d88d 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, + setCurrentlyRenderingBoundaryResourcesTarget, + createResources, + createBoundaryResources, } from './ReactServerFormatConfig'; import { constructClassInstance, @@ -147,6 +158,7 @@ type SuspenseBoundary = { completedSegments: Array<Segment>, // completed but not yet flushed segments. byteSize: number, // used to determine whether to inline children boundaries. fallbackAbortableTasks: Set<Task>, // used to cancel task on the fallback if the boundary completes or gets canceled. + resources: BoundaryResources, }; export type Task = { @@ -199,9 +211,10 @@ 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. abortableTasks: Set<Task>, - pingedTasks: Array<Task>, + pingedTasks: Array<Task>, // High priority tasks that should be worked on first. // Queues to flush in order of priority clientRenderedBoundaries: Array<SuspenseBoundary>, // Errored or client rendered but not yet flushed. completedBoundaries: Array<SuspenseBoundary>, // Completed but not yet fully flushed boundaries to show. @@ -262,6 +275,7 @@ export function createRequest( ): Request { const pingedTasks = []; const abortSet: Set<Task> = new Set(); + const resources: Resources = createResources(); const request = { destination: null, responseState, @@ -274,6 +288,7 @@ export function createRequest( nextSegmentId: 0, allPendingTasks: 0, pendingRootTasks: 0, + resources, completedRootSegment: null, abortableTasks: abortSet, pingedTasks: pingedTasks, @@ -337,6 +352,7 @@ function createSuspenseBoundary( byteSize: 0, fallbackAbortableTasks, errorDigest: null, + resources: createBoundaryResources(), }; } @@ -562,6 +578,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) { + setCurrentlyRenderingBoundaryResourcesTarget( + 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 +594,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 +619,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) { + setCurrentlyRenderingBoundaryResourcesTarget( + request.resources, + parentBoundary ? parentBoundary.resources : null, + ); + } task.blockedBoundary = parentBoundary; task.blockedSegment = parentSegment; } @@ -619,6 +652,19 @@ function renderSuspenseBoundary( popComponentStackInDEV(task); } +function hoistCompletedBoundaryResources( + request: Request, + completedBoundary: SuspenseBoundary, +): void { + 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); + } + // 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 +690,7 @@ function renderHostElement( ): void { pushBuiltInComponentStackInDEV(task, type); const segment = task.blockedSegment; + const children = pushStartInstance( segment.chunks, request.preamble, @@ -651,10 +698,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 +1782,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 +1827,13 @@ function finishedTask( } function retryTask(request: Request, task: Task): void { + if (enableFloat) { + const blockedBoundary = task.blockedBoundary; + setCurrentlyRenderingBoundaryResourcesTarget( + 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,6 +1885,9 @@ function retryTask(request: Request, task: Task): void { erroredTask(request, task.blockedBoundary, segment, x); } } finally { + if (enableFloat) { + setCurrentlyRenderingBoundaryResourcesTarget(request.resources, null); + } if (__DEV__) { currentTaskInDEV = prevTaskInDEV; } @@ -1838,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; @@ -1862,6 +1926,8 @@ export function performWork(request: Request): void { } finally { setCurrentResponseState(prevResponseState); ReactCurrentDispatcher.current = prevDispatcher; + cleanupAfterRender(previousHostDispatcher); + if (__DEV__) { ReactDebugCurrentFrame.getCurrentStack = prevGetCurrentStackImpl; } @@ -1900,6 +1966,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 +2065,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 +2089,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 +2143,12 @@ function flushCompletedBoundary( destination: Destination, boundary: SuspenseBoundary, ): boolean { + if (enableFloat) { + setCurrentlyRenderingBoundaryResourcesTarget( + request.resources, + boundary.resources, + ); + } const completedSegments = boundary.completedSegments; let i = 0; for (; i < completedSegments.length; i++) { @@ -2067,6 +2162,7 @@ function flushCompletedBoundary( request.responseState, boundary.id, boundary.rootSegmentID, + boundary.resources, ); } @@ -2075,6 +2171,12 @@ function flushPartialBoundary( destination: Destination, boundary: SuspenseBoundary, ): boolean { + if (enableFloat) { + setCurrentlyRenderingBoundaryResourcesTarget( + request.resources, + boundary.resources, + ); + } const completedSegments = boundary.completedSegments; let i = 0; for (; i < completedSegments.length; i++) { @@ -2138,8 +2240,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 +2250,23 @@ 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; 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) { + 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 ecb3218ea1..a074618c0e 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; +export const prepareToRender = $$$hostConfig.prepareToRender; +export const cleanupAfterRender = $$$hostConfig.cleanupAfterRender; + +// ------------------------- +// Resources +// ------------------------- +export const writeInitialResources = $$$hostConfig.writeInitialResources; +export const writeImmediateResources = $$$hostConfig.writeImmediateResources; +export const hoistResources = $$$hostConfig.hoistResources; +export const hoistResourcesToRoot = $$$hostConfig.hoistResourcesToRoot; +export const createResources = $$$hostConfig.createResources; +export const createBoundaryResources = $$$hostConfig.createBoundaryResources; +export const setCurrentlyRenderingBoundaryResourcesTarget = + $$$hostConfig.setCurrentlyRenderingBoundaryResourcesTarget; diff --git a/packages/react-test-renderer/src/ReactTestHostConfig.js b/packages/react-test-renderer/src/ReactTestHostConfig.js index dfccf3e9ef..1db038e411 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 = {}; @@ -321,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 +} diff --git a/packages/react-test-renderer/src/ReactTestRenderer.js b/packages/react-test-renderer/src/ReactTestRenderer.js index ae2e532b27..f8d8b8ca3a 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 23889e317b..1112e28446 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/rollup/bundles.js b/scripts/rollup/bundles.js index f93225d9d1..df67873c8c 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 => @@ -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 *******/ @@ -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 *******/ diff --git a/scripts/shared/inlinedHostConfigs.js b/scripts/shared/inlinedHostConfigs.js index 9d3cfe9b35..2cd0f84003 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, },