[Float] support title tags as Resources (#25508)

Adds a category of Resources of type `head` which will be used to track
the tags that go into the <head>

Currently only implements for `<title>`.

titles are keyed off their textContent so each time the title changes a
new resource will be created. Currently insertion is done by prepending
in the <head>. The argument here is that the newest title should "win"
if there are multiple rendered. This also helps when a navigation or
update causes a server rendered title to hang around but it is not the
most recent one.
This commit is contained in:
Josh Story
2022-10-20 23:13:32 -07:00
committed by GitHub
parent c635807875
commit 1f7a2f577b
12 changed files with 801 additions and 112 deletions
@@ -7,11 +7,7 @@
* @flow
*/
import type {
FloatRoot,
StyleResource,
ScriptResource,
} from './ReactDOMFloatClient';
import type {FloatRoot, RootResources} from './ReactDOMFloatClient';
import type {Fiber} from 'react-reconciler/src/ReactInternalTypes';
import type {ReactScopeInstance} from 'shared/ReactTypes';
import type {
@@ -53,6 +49,7 @@ const internalEventHandlersKey = '__reactEvents$' + randomKey;
const internalEventHandlerListenersKey = '__reactListeners$' + randomKey;
const internalEventHandlesSetKey = '__reactHandles$' + randomKey;
const internalRootNodeResourcesKey = '__reactResources$' + randomKey;
const internalResourceMarker = '__reactMarker$' + randomKey;
export function detachDeletedInstance(node: Instance): void {
// TODO: This function is only called on host components. I don't think all of
@@ -282,15 +279,22 @@ export function doesTargetHaveEventHandle(
return eventHandles.has(eventHandle);
}
export function getResourcesFromRoot(
root: FloatRoot,
): {styles: Map<string, StyleResource>, scripts: Map<string, ScriptResource>} {
export function getResourcesFromRoot(root: FloatRoot): RootResources {
let resources = (root: any)[internalRootNodeResourcesKey];
if (!resources) {
resources = (root: any)[internalRootNodeResourcesKey] = {
styles: new Map(),
scripts: new Map(),
head: new Map(),
};
}
return resources;
}
export function isMarkedResource(node: Node): boolean {
return !!(node: any)[internalResourceMarker];
}
export function markNodeAsResource(node: Node) {
(node: any)[internalResourceMarker] = true;
}
+190 -18
View File
@@ -24,7 +24,10 @@ import {
validatePreinitArguments,
} from '../shared/ReactDOMResourceValidation';
import {createElement, setInitialProperties} from './ReactDOMComponent';
import {getResourcesFromRoot} from './ReactDOMComponentTree';
import {
getResourcesFromRoot,
markNodeAsResource,
} from './ReactDOMComponentTree';
import {HTML_NAMESPACE} from '../shared/DOMNamespaces';
import {getCurrentRootHostContainer} from 'react-reconciler/src/ReactFiberHostContext';
@@ -85,9 +88,28 @@ export type ScriptResource = {
root: FloatRoot,
};
type HeadProps = {
[string]: mixed,
};
export type HeadResource = {
type: 'head',
instanceType: string,
props: HeadProps,
count: number,
instance: ?Element,
root: Document,
};
type Props = {[string]: mixed};
type Resource = StyleResource | ScriptResource | PreloadResource;
type Resource = StyleResource | ScriptResource | PreloadResource | HeadResource;
export type RootResources = {
styles: Map<string, StyleResource>,
scripts: Map<string, ScriptResource>,
head: Map<string, HeadResource>,
};
// Brief on purpose due to insertion by script when streaming late boundaries
// s = Status
@@ -370,6 +392,10 @@ type ScriptQualifyingProps = {
[string]: mixed,
};
function getTitleKey(child: string | number): string {
return 'title:' + child;
}
// This function is called in begin work and we should always have a currentDocument set
export function getResource(
type: string,
@@ -383,6 +409,30 @@ export function getResource(
);
}
switch (type) {
case 'title': {
let child = pendingProps.children;
if (Array.isArray(child) && child.length === 1) {
child = child[0];
}
if (typeof child === 'string' || typeof child === 'number') {
const headRoot: Document = getDocumentFromRoot(resourceRoot);
const headResources = getResourcesFromRoot(headRoot).head;
const key = getTitleKey(child);
let resource = headResources.get(key);
if (!resource) {
const titleProps = titlePropsFromRawProps(child, pendingProps);
resource = createHeadResource(
headResources,
headRoot,
'title',
key,
titleProps,
);
}
return resource;
}
return null;
}
case 'link': {
const {rel} = pendingProps;
switch (rel) {
@@ -535,6 +585,15 @@ function preloadPropsFromRawProps(
return Object.assign({}, rawBorrowedProps);
}
function titlePropsFromRawProps(
child: string | number,
rawProps: Props,
): HeadProps {
const props: HeadProps = Object.assign({}, rawProps);
props.children = child;
return props;
}
function stylePropsFromRawProps(rawProps: StyleQualifyingProps): StyleProps {
const props: StyleProps = Object.assign({}, rawProps);
props['data-precedence'] = rawProps.precedence;
@@ -554,6 +613,9 @@ function scriptPropsFromRawProps(rawProps: ScriptQualifyingProps): ScriptProps {
export function acquireResource(resource: Resource): Instance {
switch (resource.type) {
case 'head': {
return acquireHeadResource(resource);
}
case 'style': {
return acquireStyleResource(resource);
}
@@ -571,14 +633,30 @@ export function acquireResource(resource: Resource): Instance {
}
}
export function releaseResource(resource: Resource) {
export function releaseResource(resource: Resource): void {
switch (resource.type) {
case 'head': {
return releaseHeadResource(resource);
}
case 'style': {
resource.count--;
return;
}
}
}
function releaseHeadResource(resource: HeadResource): void {
if (--resource.count === 0) {
// the instance will have existed since we acquired it
const instance: Instance = (resource.instance: any);
const parent = instance.parentNode;
if (parent) {
parent.removeChild(instance);
}
resource.instance = null;
}
}
function createResourceInstance(
type: string,
props: Object,
@@ -586,9 +664,39 @@ function createResourceInstance(
): Instance {
const element = createElement(type, props, ownerDocument, HTML_NAMESPACE);
setInitialProperties(element, type, props);
markNodeAsResource(element);
return element;
}
function createHeadResource(
headResources: Map<string, HeadResource>,
root: Document,
instanceType: string,
key: string,
props: HeadProps,
): HeadResource {
if (__DEV__) {
if (headResources.has(key)) {
console.error(
'createHeadResource was called when a head Resource matching the same key already exists. This is a bug in React.',
);
}
}
const resource: HeadResource = {
type: 'head',
instanceType,
props,
count: 0,
instance: null,
root,
};
headResources.set(key, resource);
return resource;
}
function createStyleResource(
styleResources: Map<string, StyleResource>,
root: FloatRoot,
@@ -754,6 +862,8 @@ function createScriptResource(
(resource: any)._dev_preload_props = preloadProps;
}
}
} else {
markNodeAsResource(existingEl);
}
return resource;
@@ -784,7 +894,9 @@ function createPreloadResource(
);
if (!element) {
element = createResourceInstance('link', props, ownerDocument);
insertResourceInstance(element, ownerDocument);
appendResourceInstance(element, ownerDocument);
} else {
markNodeAsResource(element);
}
return {
type: 'preload',
@@ -795,8 +907,41 @@ function createPreloadResource(
};
}
function acquireHeadResource(resource: HeadResource): Instance {
resource.count++;
let instance = resource.instance;
if (!instance) {
const {props, root, instanceType} = resource;
switch (instanceType) {
case 'title': {
const titles = root.querySelectorAll('title');
for (let i = 0; i < titles.length; i++) {
if (titles[i].textContent === props.children) {
instance = resource.instance = titles[i];
markNodeAsResource(instance);
return instance;
}
}
}
}
instance = resource.instance = createResourceInstance(
instanceType,
props,
root,
);
if (instanceType === 'title') {
prependResourceInstance(instance, root);
} else {
appendResourceInstance(instance, root);
}
}
return instance;
}
function acquireStyleResource(resource: StyleResource): Instance {
if (!resource.instance) {
let instance = resource.instance;
if (!instance) {
const {props, root, precedence} = resource;
const limitedEscapedHref = escapeSelectorAttributeValueInsideDoubleQuotes(
props.href,
@@ -805,7 +950,8 @@ function acquireStyleResource(resource: StyleResource): Instance {
`link[rel="stylesheet"][data-precedence][href="${limitedEscapedHref}"]`,
);
if (existingEl) {
resource.instance = existingEl;
instance = resource.instance = existingEl;
markNodeAsResource(instance);
resource.preloaded = true;
const loadingState: ?StyleResourceLoadingState = (existingEl: any)._p;
if (loadingState) {
@@ -830,7 +976,7 @@ function acquireStyleResource(resource: StyleResource): Instance {
resource.loaded = true;
}
} else {
const instance = createResourceInstance(
instance = resource.instance = createResourceInstance(
'link',
resource.props,
getDocumentFromRoot(root),
@@ -838,16 +984,15 @@ function acquireStyleResource(resource: StyleResource): Instance {
attachLoadListeners(instance, resource);
insertStyleInstance(instance, precedence, root);
resource.instance = instance;
}
}
resource.count++;
// $FlowFixMe[incompatible-return] found when upgrading Flow
return resource.instance;
return instance;
}
function acquireScriptResource(resource: ScriptResource): Instance {
if (!resource.instance) {
let instance = resource.instance;
if (!instance) {
const {props, root} = resource;
const limitedEscapedSrc = escapeSelectorAttributeValueInsideDoubleQuotes(
props.src,
@@ -856,19 +1001,19 @@ function acquireScriptResource(resource: ScriptResource): Instance {
`script[async][src="${limitedEscapedSrc}"]`,
);
if (existingEl) {
resource.instance = existingEl;
instance = resource.instance = existingEl;
markNodeAsResource(instance);
} else {
const instance = createResourceInstance(
instance = resource.instance = createResourceInstance(
'script',
resource.props,
getDocumentFromRoot(root),
);
insertResourceInstance(instance, getDocumentFromRoot(root));
resource.instance = instance;
appendResourceInstance(instance, getDocumentFromRoot(root));
}
}
return resource.instance;
return instance;
}
function attachLoadListeners(instance: Instance, resource: StyleResource) {
@@ -968,14 +1113,38 @@ function insertStyleInstance(
}
}
function insertResourceInstance(
function prependResourceInstance(
instance: Instance,
ownerDocument: Document,
): void {
if (__DEV__) {
if (instance.tagName === 'LINK' && (instance: any).rel === 'stylesheet') {
console.error(
'insertResourceInstance was called with a stylesheet. Stylesheets must be' +
'prependResourceInstance was called with a stylesheet. Stylesheets must be' +
' inserted with insertStyleInstance instead. This is a bug in React.',
);
}
}
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 appendResourceInstance(
instance: Instance,
ownerDocument: Document,
): void {
if (__DEV__) {
if (instance.tagName === 'LINK' && (instance: any).rel === 'stylesheet') {
console.error(
'appendResourceInstance was called with a stylesheet. Stylesheets must be' +
' inserted with insertStyleInstance instead. This is a bug in React.',
);
}
@@ -993,6 +1162,9 @@ function insertResourceInstance(
export function isHostResourceType(type: string, props: Props): boolean {
switch (type) {
case 'title': {
return true;
}
case 'link': {
switch (props.rel) {
case 'stylesheet': {
+46 -40
View File
@@ -25,6 +25,7 @@ import {
getInstanceFromNode as getInstanceFromNodeDOMTree,
isContainerMarkedAsRoot,
detachDeletedInstance,
isMarkedResource,
} from './ReactDOMComponentTree';
export {detachDeletedInstance};
import {hasRole} from './DOMAccessibilityRoles';
@@ -58,6 +59,7 @@ import {
TEXT_NODE,
COMMENT_NODE,
DOCUMENT_NODE,
DOCUMENT_TYPE_NODE,
DOCUMENT_FRAGMENT_NODE,
} from '../shared/HTMLNodeType';
import dangerousStyleValue from '../shared/dangerousStyleValue';
@@ -711,50 +713,15 @@ export function unhideTextInstance(
export function clearContainer(container: Container): void {
if (enableHostSingletons) {
// We have refined the container to Element type
const nodeType = container.nodeType;
if (nodeType === DOCUMENT_NODE || nodeType === ELEMENT_NODE) {
switch (container.nodeName) {
case '#document':
case 'HTML':
case 'HEAD':
case 'BODY': {
let node = container.firstChild;
while (node) {
const nextNode = node.nextSibling;
const nodeName = node.nodeName;
switch (nodeName) {
case 'HTML':
case 'HEAD':
case 'BODY': {
clearContainer((node: any));
// If these singleton instances had previously been rendered with React they
// may still hold on to references to the previous fiber tree. We detatch them
// prospectiveyl to reset them to a baseline starting state since we cannot create
// new instances.
detachDeletedInstance((node: any));
break;
}
case 'STYLE': {
break;
}
case 'LINK': {
if (
((node: any): HTMLLinkElement).rel.toLowerCase() ===
'stylesheet'
) {
break;
}
}
// eslint-disable-next-line no-fallthrough
default: {
container.removeChild(node);
}
}
node = nextNode;
}
case 'BODY':
clearContainerChildren(container);
return;
}
default: {
container.textContent = '';
}
@@ -775,6 +742,42 @@ export function clearContainer(container: Container): void {
}
}
function clearContainerChildren(container: Node) {
let node;
let nextNode: ?Node = container.firstChild;
if (nextNode && nextNode.nodeType === DOCUMENT_TYPE_NODE) {
nextNode = nextNode.nextSibling;
}
while (nextNode) {
node = nextNode;
nextNode = nextNode.nextSibling;
switch (node.nodeName) {
case 'HTML':
case 'HEAD':
case 'BODY': {
const element: Element = (node: any);
clearContainerChildren(element);
// If these singleton instances had previously been rendered with React they
// may still hold on to references to the previous fiber tree. We detatch them
// prospectively to reset them to a baseline starting state since we cannot create
// new instances.
detachDeletedInstance(element);
continue;
}
case 'STYLE': {
continue;
}
case 'LINK': {
if (((node: any): HTMLLinkElement).rel.toLowerCase() === 'stylesheet') {
continue;
}
}
}
container.removeChild(node);
}
return;
}
// Making this so we can eventually move all of the instance caching to the commit phase.
// Currently this is only used to associate fiber and props to instances for hydrating
// HostSingletons. The reason we need it here is we only want to make this binding on commit
@@ -923,6 +926,7 @@ function getNextHydratable(node) {
}
break;
}
case 'TITLE':
case 'HTML':
case 'HEAD':
case 'BODY': {
@@ -948,6 +952,9 @@ function getNextHydratable(node) {
}
break;
}
case 'TITLE': {
continue;
}
case 'STYLE': {
const styleEl: HTMLStyleElement = (element: any);
if (styleEl.hasAttribute('data-precedence')) {
@@ -1666,9 +1673,8 @@ export function clearSingleton(instance: Instance): void {
while (node) {
const nextNode = node.nextSibling;
const nodeName = node.nodeName;
if (getInstanceFromNodeDOMTree(node)) {
// retain nodes owned by React
} else if (
if (
isMarkedResource(node) ||
nodeName === 'HEAD' ||
nodeName === 'BODY' ||
nodeName === 'STYLE' ||
@@ -68,6 +68,18 @@ type ScriptResource = {
hint: PreloadResource,
};
type HeadProps = {
[string]: mixed,
};
type HeadResource = {
type: 'head',
instanceType: string,
props: HeadProps,
flushed: boolean,
allowLate: boolean,
};
export type Resource = PreloadResource | StyleResource | ScriptResource;
export type Resources = {
@@ -75,8 +87,10 @@ export type Resources = {
preloadsMap: Map<string, PreloadResource>,
stylesMap: Map<string, StyleResource>,
scriptsMap: Map<string, ScriptResource>,
headsMap: Map<string, HeadResource>,
// Flushing queues for Resource dependencies
charset: null | HeadResource,
fontPreloads: Set<PreloadResource>,
// usedImagePreloads: Set<PreloadResource>,
precedences: Map<string, Set<StyleResource>>,
@@ -86,6 +100,7 @@ export type Resources = {
explicitStylePreloads: Set<PreloadResource>,
// explicitImagePreloads: Set<PreloadResource>,
explicitScriptPreloads: Set<PreloadResource>,
headResources: Set<HeadResource>,
// Module-global-like reference for current boundary resources
boundaryResources: ?BoundaryResources,
@@ -99,8 +114,10 @@ export function createResources(): Resources {
preloadsMap: new Map(),
stylesMap: new Map(),
scriptsMap: new Map(),
headsMap: new Map(),
// cleared on flush
charset: null,
fontPreloads: new Set(),
// usedImagePreloads: new Set(),
precedences: new Map(),
@@ -110,6 +127,7 @@ export function createResources(): Resources {
explicitStylePreloads: new Set(),
// explicitImagePreloads: new Set(),
explicitScriptPreloads: new Set(),
headResources: new Set(),
// like a module global for currently rendering boundary
boundaryResources: null,
@@ -563,6 +581,74 @@ function adoptPreloadPropsForScriptProps(
resourceProps.integrity = preloadProps.integrity;
}
function createHeadResource(
resources: Resources,
key: string,
instanceType: string,
props: HeadProps,
): HeadResource {
if (__DEV__) {
if (resources.headsMap.has(key)) {
console.error(
'createScriptResource was called when a script Resource matching the same src already exists. This is a bug in React.',
);
}
}
const resource: HeadResource = {
type: 'head',
instanceType,
props,
flushed: false,
allowLate: true,
};
resources.headsMap.set(key, resource);
return resource;
}
function getTitleKey(child: string | number): string {
return 'title' + child;
}
function titlePropsFromRawProps(
child: string | number,
rawProps: Props,
): HeadProps {
const props = Object.assign({}, rawProps);
props.children = child;
return props;
}
export function resourcesFromElement(type: string, props: Props): boolean {
if (!currentResources) {
throw new Error(
'"currentResources" was expected to exist. This is a bug in React.',
);
}
const resources = currentResources;
switch (type) {
case 'title': {
let child = props.children;
if (Array.isArray(child) && child.length === 1) {
child = child[0];
}
if (typeof child === 'string' || typeof child === 'number') {
const key = getTitleKey(child);
let resource = resources.headsMap.get(key);
if (!resource) {
const titleProps = titlePropsFromRawProps(child, props);
resource = createHeadResource(resources, key, 'title', titleProps);
resources.headResources.add(resource);
}
return true;
}
return false;
}
}
return false;
}
// Construct a resource from link props.
export function resourcesFromLink(props: Props): boolean {
if (!currentResources) {
@@ -64,6 +64,7 @@ import isArray from 'shared/isArray';
import {
prepareToRenderResources,
finishRenderingResources,
resourcesFromElement,
resourcesFromLink,
resourcesFromScript,
ReactDOMServerDispatcher,
@@ -1277,6 +1278,20 @@ function pushStartTitle(
target: Array<Chunk | PrecomputedChunk>,
props: Object,
responseState: ResponseState,
): ReactNodeList {
if (enableFloat && resourcesFromElement('title', props)) {
// We have converted this link exclusively to a resource and no longer
// need to emit it
return null;
}
return pushStartTitleImpl(target, props, responseState);
}
function pushStartTitleImpl(
target: Array<Chunk | PrecomputedChunk>,
props: Object,
responseState: ResponseState,
): ReactNodeList {
target.push(startChunkForTag('title'));
@@ -2310,6 +2325,7 @@ export function writeInitialResources(
usedScriptPreloads,
explicitStylePreloads,
explicitScriptPreloads,
headResources,
} = resources;
fontPreloads.forEach(r => {
@@ -2360,6 +2376,18 @@ export function writeInitialResources(
explicitScriptPreloads.forEach(flushLinkResource);
explicitScriptPreloads.clear();
headResources.forEach(r => {
if (r.instanceType === 'title') {
pushStartTitleImpl(target, r.props, responseState);
if (typeof r.props.children === 'string') {
target.push(escapeTextForBrowser(stringToChunk(r.props.children)));
}
pushEndInstance(target, target, 'title', r.props);
}
r.flushed = true;
});
headResources.clear();
let i;
let r = true;
for (i = 0; i < target.length - 1; i++) {
@@ -2392,6 +2420,7 @@ export function writeImmediateResources(
usedScriptPreloads,
explicitStylePreloads,
explicitScriptPreloads,
headResources,
} = resources;
fontPreloads.forEach(r => {
@@ -2422,6 +2451,18 @@ export function writeImmediateResources(
explicitScriptPreloads.forEach(flushLinkResource);
explicitScriptPreloads.clear();
headResources.forEach(r => {
if (r.instanceType === 'title') {
pushStartTitle(target, r.props, responseState);
if (typeof r.props.children === 'string') {
target.push(escapeTextForBrowser(stringToChunk(r.props.children)));
}
pushEndInstance(target, target, 'title', r.props);
}
r.flushed = true;
});
headResources.clear();
let i;
let r = true;
for (i = 0; i < target.length - 1; i++) {
@@ -15,4 +15,5 @@ export const ELEMENT_NODE = 1;
export const TEXT_NODE = 3;
export const COMMENT_NODE = 8;
export const DOCUMENT_NODE = 9;
export const DOCUMENT_TYPE_NODE = 10;
export const DOCUMENT_FRAGMENT_NODE = 11;
+21 -12
View File
@@ -4968,18 +4968,27 @@ describe('ReactDOMFizzServer', () => {
},
});
expect(Scheduler).toFlushAndYield([]);
expect(errors).toEqual(
[
gate(flags => flags.enableClientRenderFallbackOnTextMismatch)
? 'Text content does not match server-rendered HTML.'
: null,
'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.',
].filter(Boolean),
);
expect(getVisibleChildren(container)).toEqual(
<title>{['hello1', 'hello2']}</title>,
);
if (gate(flags => flags.enableFloat)) {
expect(errors).toEqual([]);
// with float, the title doesn't render on the client because it is not a simple child
// we end up seeing the server rendered title
expect(getVisibleChildren(container)).toEqual(
<title>{'hello1<!-- -->hello2'}</title>,
);
} else {
expect(errors).toEqual(
[
gate(flags => flags.enableClientRenderFallbackOnTextMismatch)
? 'Text content does not match server-rendered HTML.'
: null,
'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.',
].filter(Boolean),
);
expect(getVisibleChildren(container)).toEqual(
<title>{['hello1', 'hello2']}</title>,
);
}
} finally {
console.error = originalConsoleError;
}
+330
View File
@@ -235,6 +235,80 @@ describe('ReactDOMFloat', () => {
return readText(text);
}
// @gate enableFloat
it('can render resources before singletons', async () => {
const root = ReactDOMClient.createRoot(document);
root.render(
<>
<title>foo</title>
<html>
<head>
<link rel="foo" href="foo" />
</head>
<body>hello world</body>
</html>
</>,
);
try {
expect(Scheduler).toFlushWithoutYielding();
} catch (e) {
// for DOMExceptions that happen when expecting this test to fail we need
// to clear the scheduler first otherwise the expected failure will fail
expect(Scheduler).toFlushWithoutYielding();
throw e;
}
expect(getMeaningfulChildren(document)).toEqual(
<html>
<head>
<title>foo</title>
<link rel="foo" href="foo" />
</head>
<body>hello world</body>
</html>,
);
});
// @gate enableFloat
it('can acquire a resource after releasing it in the same commit', async () => {
const root = ReactDOMClient.createRoot(container);
root.render(
<>
<title>foo</title>
</>,
);
expect(Scheduler).toFlushWithoutYielding();
expect(getMeaningfulChildren(document)).toEqual(
<html>
<head>
<title>foo</title>
</head>
<body>
<div id="container" />
</body>
</html>,
);
// title is keyed off children so this second resource should match the first one
root.render(
<>
{null}
<title data-new="new">foo</title>
</>,
);
expect(Scheduler).toFlushWithoutYielding();
// we don't see the attribute because the resource is the same and was not reconstructed
expect(getMeaningfulChildren(document)).toEqual(
<html>
<head>
<title>foo</title>
</head>
<body>
<div id="container" />
</body>
</html>,
);
});
// @gate enableFloat
it('errors if the document does not contain a head when inserting a resource', async () => {
document.head.parentNode.removeChild(document.head);
@@ -866,6 +940,261 @@ describe('ReactDOMFloat', () => {
});
});
describe('head resources', () => {
// @gate enableFloat
it('can rendering title tags anywhere in the tree', async () => {
await actIntoEmptyDocument(() => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<>
<title>before</title>
<>
<html>
<head>
<title>in head</title>
</head>
<body>
<div>
<title>during</title>
hello world
</div>
</body>
</html>
</>
<title>after</title>
</>,
);
pipe(writable);
});
expect(getMeaningfulChildren(document)).toEqual(
<html>
<head>
<title>before</title>
<title>in head</title>
<title>during</title>
<title>after</title>
</head>
<body>
<div>hello world</div>
</body>
</html>,
);
ReactDOMClient.hydrateRoot(
document,
<>
<title>before</title>
<>
<html>
<head>
<title>in head</title>
</head>
<body>
<div>
<title>during</title>
hello world
</div>
</body>
</html>
</>
<title>after</title>
</>,
);
expect(Scheduler).toFlushWithoutYielding();
expect(getMeaningfulChildren(document)).toEqual(
<html>
<head>
<title>before</title>
<title>in head</title>
<title>during</title>
<title>after</title>
</head>
<body>
<div>hello world</div>
</body>
</html>,
);
});
// @gate enableFloat
it('prepends new titles on the client so newer ones override older ones, including orphaned server rendered titles', async () => {
await actIntoEmptyDocument(() => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<html>
<head>
<title>server</title>
</head>
<body>
<div>hello world</div>
</body>
</html>,
);
pipe(writable);
});
expect(getMeaningfulChildren(document)).toEqual(
<html>
<head>
<title>server</title>
</head>
<body>
<div>hello world</div>
</body>
</html>,
);
ReactDOMClient.hydrateRoot(
document,
<html>
<title>html</title>
<head>
<title>head</title>
</head>
<body>
<title>body</title>
<div>hello world</div>
</body>
</html>,
);
expect(Scheduler).toFlushWithoutYielding();
expect(getMeaningfulChildren(document)).toEqual(
<html>
<head>
<title>body</title>
<title>head</title>
<title>html</title>
<title>server</title>
</head>
<body>
<div>hello world</div>
</body>
</html>,
);
});
// @gate enableFloat
it('keys titles on text children and only removes them when no more instances refer to that title', async () => {
const root = ReactDOMClient.createRoot(container);
root.render(
<div>
<title>{[2]}</title>hello world<title>2</title>
</div>,
);
expect(Scheduler).toFlushWithoutYielding();
expect(getMeaningfulChildren(document)).toEqual(
<html>
<head>
<title>2</title>
</head>
<body>
<div id="container">
<div>hello world</div>
</div>
</body>
</html>,
);
root.render(
<div>
{null}hello world<title>2</title>
</div>,
);
expect(Scheduler).toFlushWithoutYielding();
expect(getMeaningfulChildren(document)).toEqual(
<html>
<head>
<title>2</title>
</head>
<body>
<div id="container">
<div>hello world</div>
</div>
</body>
</html>,
);
root.render(
<div>
{null}hello world{null}
</div>,
);
expect(Scheduler).toFlushWithoutYielding();
expect(getMeaningfulChildren(document)).toEqual(
<html>
<head />
<body>
<div id="container">
<div>hello world</div>
</div>
</body>
</html>,
);
});
// @gate enableFloat
it('can render a title before a singleton even if that singleton clears its contents', async () => {
await actIntoEmptyDocument(() => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<>
<title>foo</title>
<html>
<head />
<body>
<div>server</div>
</body>
</html>
,
</>,
);
pipe(writable);
});
const errors = [];
ReactDOMClient.hydrateRoot(
document,
<>
<title>foo</title>
<html>
<head />
<body>
<div>client</div>
</body>
</html>
</>,
{
onRecoverableError(err) {
errors.push(err.message);
},
},
);
try {
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},
);
} catch (e) {
// When gates are false this test fails on a DOMException if you don't clear the scheduler after catching.
// When gates are true this branch should not be hit
expect(Scheduler).toFlushWithoutYielding();
throw e;
}
expect(getMeaningfulChildren(document)).toEqual(
<html>
<head>
<title>foo</title>
</head>
<body>
<div>client</div>
</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 () => {
@@ -1229,6 +1558,7 @@ describe('ReactDOMFloat', () => {
<link rel="stylesheet" href="foo" data-precedence="foo" />
<link rel="stylesheet" href="bar" data-precedence="bar" />
<link rel="stylesheet" href="baz" data-precedence="baz" />
<link rel="preload" href="baz" as="style" />
</head>
<body>hello</body>
</html>,
@@ -155,8 +155,8 @@ describe('ReactDOM HostSingleton', () => {
expect(getVisibleChildren(document)).toEqual(
<html>
<head lang="es" data-foo="foo">
<title>Hello</title>
<title>Hola</title>
<title>Hello</title>
</head>
<body />
</html>,
@@ -241,8 +241,8 @@ describe('ReactDOM HostSingleton', () => {
<link rel="preload" href="resource" as="style" />
<link rel="preload" href="3rdparty" as="style" />
<link rel="preload" href="3rdparty2" as="style" />
<link rel="stylesheet" href="resource" />
<title>a server title</title>
<link rel="stylesheet" href="resource" />
<link rel="stylesheet" href="3rdparty" />
<link rel="stylesheet" href="3rdparty2" />
</head>
@@ -287,10 +287,10 @@ describe('ReactDOM HostSingleton', () => {
expect(getVisibleChildren(document)).toEqual(
<html data-client-foo="foo">
<head>
<title>a client title</title>
<link rel="stylesheet" href="resource" />
<link rel="stylesheet" href="3rdparty" />
<link rel="stylesheet" href="3rdparty2" />
<title>a client title</title>
</head>
<body data-client-baz="baz">
<style>
@@ -326,10 +326,10 @@ describe('ReactDOM HostSingleton', () => {
expect(getVisibleChildren(document)).toEqual(
<html data-client-foo="foo">
<head>
<title>a client title</title>
<link rel="stylesheet" href="resource" />
<link rel="stylesheet" href="3rdparty" />
<link rel="stylesheet" href="3rdparty2" />
<title>a client title</title>
<meta />
</head>
<body data-client-baz="baz">
@@ -365,10 +365,10 @@ describe('ReactDOM HostSingleton', () => {
expect(getVisibleChildren(document)).toEqual(
<html data-client-foo="foo">
<head>
<title>a client title</title>
<link rel="stylesheet" href="resource" />
<link rel="stylesheet" href="3rdparty" />
<link rel="stylesheet" href="3rdparty2" />
<title>a client title</title>
</head>
<body data-client-baz="baz">
<style>
@@ -401,10 +401,10 @@ describe('ReactDOM HostSingleton', () => {
expect(getVisibleChildren(document)).toEqual(
<html data-client-foo="foo">
<head>
<title>a client title</title>
<link rel="stylesheet" href="resource" />
<link rel="stylesheet" href="3rdparty" />
<link rel="stylesheet" href="3rdparty2" />
<title>a client title</title>
</head>
<body>
<style>
@@ -472,19 +472,15 @@ describe('ReactDOM HostSingleton', () => {
expect(Scheduler).toFlushWithoutYielding();
}).toErrorDev(
[
`Warning: Expected server HTML to contain a matching <title> in <head>.
in title (at **)
in head (at **)
`Warning: Expected server HTML to contain a matching <div> in <body>.
in div (at **)
in body (at **)
in html (at **)`,
`Warning: An error occurred during hydration. The server HTML was replaced with client content in <#document>.`,
],
{withoutStack: 1},
);
expect(hydrationErrors).toEqual([
[
'Hydration failed because the initial UI does not match what was rendered on the server.',
'at title',
],
[
'Hydration failed because the initial UI does not match what was rendered on the server.',
'at div',
@@ -502,10 +498,10 @@ describe('ReactDOM HostSingleton', () => {
expect(getVisibleChildren(document)).toEqual(
<html data-client-foo="foo">
<head>
<title>a client title</title>
<link rel="stylesheet" href="resource" />
<link rel="stylesheet" href="3rdparty" />
<link rel="stylesheet" href="3rdparty2" />
<title>a client title</title>
</head>
<body data-client-baz="baz">
<style>
@@ -768,9 +764,9 @@ describe('ReactDOM HostSingleton', () => {
expect(getVisibleChildren(document)).toEqual(
<html>
<head>
<title>something new</title>
<link rel="stylesheet" href="headbefore" />
<link rel="stylesheet" href="headafter" />
<title>something new</title>
</head>
<body>
<link rel="stylesheet" href="bodybefore" />
@@ -804,9 +800,9 @@ describe('ReactDOM HostSingleton', () => {
expect(getVisibleChildren(document)).toEqual(
<html>
<head>
<title>something new</title>
<link rel="stylesheet" href="before" />
<link rel="stylesheet" href="after" />
<title>something new</title>
</head>
<body />
</html>,
@@ -979,4 +975,37 @@ describe('ReactDOM HostSingleton', () => {
</html>,
);
});
// @gate enableHostSingletons
it('allows for hydrating without a head', async () => {
await actIntoEmptyDocument(() => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<html>
<body>foo</body>
</html>,
);
pipe(writable);
});
expect(getVisibleChildren(document)).toEqual(
<html>
<head />
<body>foo</body>
</html>,
);
ReactDOMClient.hydrateRoot(
document,
<html>
<body>foo</body>
</html>,
);
expect(Scheduler).toFlushWithoutYielding();
expect(getVisibleChildren(document)).toEqual(
<html>
<head />
<body>foo</body>
</html>,
);
});
});
@@ -253,10 +253,21 @@ describe('rendering React components at document', () => {
}
}
// getTestDocument() has an extra <meta> that we didn't render.
expect(() =>
ReactDOM.hydrate(<Component text="Hello world" />, testDocument),
).toErrorDev('Did not expect server HTML to contain a <meta> in <head>.');
if (gate(flags => flags.enableFloat)) {
// with float the title no longer is a hydration mismatch so we get an error on the body mismatch
expect(() =>
ReactDOM.hydrate(<Component text="Hello world" />, testDocument),
).toErrorDev(
'Expected server HTML to contain a matching text node for "Hello world" in <body>',
);
} else {
// getTestDocument() has an extra <meta> that we didn't render.
expect(() =>
ReactDOM.hydrate(<Component text="Hello world" />, testDocument),
).toErrorDev(
'Did not expect server HTML to contain a <meta> in <head>.',
);
}
expect(testDocument.body.innerHTML).toBe('Hello world');
});
@@ -1599,13 +1599,13 @@ function updateHostResource(current, workInProgress, renderLanes) {
workInProgress.pendingProps,
currentProps,
);
reconcileChildren(
current,
workInProgress,
workInProgress.pendingProps.children,
renderLanes,
);
return workInProgress.child;
// Resources never have reconciler managed children. It is possible for
// the host implementation of getResource to consider children in the
// resource construction but they will otherwise be discarded. In practice
// this precludes all but the simplest children and Host specific warnings
// should be implemented to warn when children are passsed when otherwise not
// expected
return null;
}
function updateHostSingleton(
@@ -1599,13 +1599,13 @@ function updateHostResource(current, workInProgress, renderLanes) {
workInProgress.pendingProps,
currentProps,
);
reconcileChildren(
current,
workInProgress,
workInProgress.pendingProps.children,
renderLanes,
);
return workInProgress.child;
// Resources never have reconciler managed children. It is possible for
// the host implementation of getResource to consider children in the
// resource construction but they will otherwise be discarded. In practice
// this precludes all but the simplest children and Host specific warnings
// should be implemented to warn when children are passsed when otherwise not
// expected
return null;
}
function updateHostSingleton(