Skip to content

Commit 796d318

Browse files
authored
Implement basic stylesheet Resources for react-dom (#25060)
Implement basic support for "Resources". In the context of this commit, the only thing that is currently a Resource are <link rel="stylesheet" precedence="some-value" ...> Resources can be rendered anywhere in the react tree, even outside of normal parenting rules, for instance you can render a resource before you have rendered the <html><head> tags for your application. In the stream we reorder this so the browser always receives valid HTML and resources are emitted either in place (normal circumstances) or at the top of the <head> (when you render them above or before the <head> in your react tree) On the client, resources opt into an entirely different hydration path. Instead of matching the location within the Document these resources are queried for in the entire document. It is an error to have more than one resource with the same href attribute. The use of precedence here as an opt-in signal for resourcifying the link is in preparation for a more complete Resource implementation which will dedupe resource references (multiple will be valid), hoist to the appropriate container (body, head, or elsewhere), order (according to precedence) and Suspend boundaries that depend on them. More details will come in the coming weeks on this plan. This feature is gated by an experimental flag and will only be made available in experimental builds until some future time.
1 parent 32baab3 commit 796d318

24 files changed

+729
-26
lines changed

packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js

+402-2
Large diffs are not rendered by default.

packages/react-dom/src/__tests__/ReactDOMRoot-test.js

+1
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,7 @@ describe('ReactDOMRoot', () => {
358358
);
359359
});
360360

361+
// @gate !__DEV__ || !enableFloat
361362
it('warns if updating a root that has had its contents removed', async () => {
362363
const root = ReactDOMClient.createRoot(container);
363364
root.render(<div>Hi</div>);

packages/react-dom/src/client/ReactDOMComponent.js

+13-1
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ import {
7373
enableTrustedTypesIntegration,
7474
enableCustomElementPropertySupport,
7575
enableClientRenderFallbackOnTextMismatch,
76+
enableFloat,
7677
} from 'shared/ReactFeatureFlags';
7778
import {
7879
mediaEventTypes,
@@ -257,7 +258,7 @@ export function checkForUnmatchedText(
257258
}
258259
}
259260

260-
function getOwnerDocumentFromRootContainer(
261+
export function getOwnerDocumentFromRootContainer(
261262
rootContainerElement: Element | Document | DocumentFragment,
262263
): Document {
263264
return rootContainerElement.nodeType === DOCUMENT_NODE
@@ -1018,6 +1019,17 @@ export function diffHydratedProperties(
10181019
: getPropertyInfo(propKey);
10191020
if (rawProps[SUPPRESS_HYDRATION_WARNING] === true) {
10201021
// Don't bother comparing. We're ignoring all these warnings.
1022+
} else if (
1023+
enableFloat &&
1024+
tag === 'link' &&
1025+
rawProps.rel === 'stylesheet' &&
1026+
propKey === 'precedence'
1027+
) {
1028+
// @TODO this is a temporary rule while we haven't implemented HostResources yet. This is used to allow
1029+
// for hydrating Resources (at the moment, stylesheets with a precedence prop) by using a data attribute.
1030+
// When we implement HostResources there will be no hydration directly so this code can be deleted
1031+
// $FlowFixMe - Should be inferred as not undefined.
1032+
extraAttributeNames.delete('data-rprec');
10211033
} else if (
10221034
propKey === SUPPRESS_CONTENT_EDITABLE_WARNING ||
10231035
propKey === SUPPRESS_HYDRATION_WARNING ||

packages/react-dom/src/client/ReactDOMHostConfig.js

+65-3
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
warnForDeletedHydratableText,
4141
warnForInsertedHydratedElement,
4242
warnForInsertedHydratedText,
43+
getOwnerDocumentFromRootContainer,
4344
} from './ReactDOMComponent';
4445
import {getSelectionInformation, restoreSelection} from './ReactInputSelection';
4546
import setTextContent from './setTextContent';
@@ -64,6 +65,7 @@ import {retryIfBlockedOn} from '../events/ReactDOMEventReplaying';
6465
import {
6566
enableCreateEventHandleAPI,
6667
enableScopeAPI,
68+
enableFloat,
6769
} from 'shared/ReactFeatureFlags';
6870
import {HostComponent, HostText} from 'react-reconciler/src/ReactWorkTags';
6971
import {listenToAllSupportedEvents} from '../events/DOMPluginEventSystem';
@@ -675,6 +677,14 @@ export function clearContainer(container: Container): void {
675677

676678
export const supportsHydration = true;
677679

680+
export function isHydratableResource(type: string, props: Props) {
681+
return (
682+
type === 'link' &&
683+
typeof (props: any).precedence === 'string' &&
684+
(props: any).rel === 'stylesheet'
685+
);
686+
}
687+
678688
export function canHydrateInstance(
679689
instance: HydratableInstance,
680690
type: string,
@@ -769,10 +779,25 @@ export function registerSuspenseInstanceRetry(
769779

770780
function getNextHydratable(node) {
771781
// Skip non-hydratable nodes.
772-
for (; node != null; node = node.nextSibling) {
782+
for (; node != null; node = ((node: any): Node).nextSibling) {
773783
const nodeType = node.nodeType;
774-
if (nodeType === ELEMENT_NODE || nodeType === TEXT_NODE) {
775-
break;
784+
if (enableFloat) {
785+
if (nodeType === ELEMENT_NODE) {
786+
if (
787+
((node: any): Element).tagName === 'LINK' &&
788+
((node: any): Element).hasAttribute('data-rprec')
789+
) {
790+
continue;
791+
}
792+
break;
793+
}
794+
if (nodeType === TEXT_NODE) {
795+
break;
796+
}
797+
} else {
798+
if (nodeType === ELEMENT_NODE || nodeType === TEXT_NODE) {
799+
break;
800+
}
776801
}
777802
if (nodeType === COMMENT_NODE) {
778803
const nodeData = (node: any).data;
@@ -873,6 +898,43 @@ export function hydrateSuspenseInstance(
873898
precacheFiberNode(internalInstanceHandle, suspenseInstance);
874899
}
875900

901+
export function getMatchingResourceInstance(
902+
type: string,
903+
props: Props,
904+
rootHostContainer: Container,
905+
): ?Instance {
906+
if (enableFloat) {
907+
switch (type) {
908+
case 'link': {
909+
if (typeof (props: any).href !== 'string') {
910+
return null;
911+
}
912+
const selector = `link[rel="stylesheet"][data-rprec][href="${
913+
(props: any).href
914+
}"]`;
915+
const link = getOwnerDocumentFromRootContainer(
916+
rootHostContainer,
917+
).querySelector(selector);
918+
if (__DEV__) {
919+
const allLinks = getOwnerDocumentFromRootContainer(
920+
rootHostContainer,
921+
).querySelectorAll(selector);
922+
if (allLinks.length > 1) {
923+
console.error(
924+
'Stylesheet resources need a unique representation in the DOM while hydrating' +
925+
' and more than one matching DOM Node was found. To fix, ensure you are only' +
926+
' rendering one stylesheet link with an href attribute of "%s".',
927+
(props: any).href,
928+
);
929+
}
930+
}
931+
return link;
932+
}
933+
}
934+
}
935+
return null;
936+
}
937+
876938
export function getNextHydratableInstanceAfterSuspenseInstance(
877939
suspenseInstance: SuspenseInstance,
878940
): null | HydratableInstance {

packages/react-dom/src/client/ReactDOMRoot.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type {
1515

1616
import {queueExplicitHydrationTarget} from '../events/ReactDOMEventReplaying';
1717
import {REACT_ELEMENT_TYPE} from 'shared/ReactSymbols';
18+
import {enableFloat} from 'shared/ReactFeatureFlags';
1819

1920
export type RootType = {
2021
render(children: ReactNodeList): void,
@@ -118,7 +119,7 @@ ReactDOMHydrationRoot.prototype.render = ReactDOMRoot.prototype.render = functio
118119

119120
const container = root.containerInfo;
120121

121-
if (container.nodeType !== COMMENT_NODE) {
122+
if (!enableFloat && container.nodeType !== COMMENT_NODE) {
122123
const hostInstance = findHostInstanceWithNoPortals(root.current);
123124
if (hostInstance) {
124125
if (hostInstance.parentNode !== container) {

packages/react-dom/src/server/ReactDOMServerFormatConfig.js

+101-8
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {Children} from 'react';
2020
import {
2121
enableFilterEmptyStringAttributesDOM,
2222
enableCustomElementPropertySupport,
23+
enableFloat,
2324
} from 'shared/ReactFeatureFlags';
2425

2526
import type {
@@ -1056,6 +1057,52 @@ function pushStartTextArea(
10561057
return null;
10571058
}
10581059

1060+
function pushLink(
1061+
target: Array<Chunk | PrecomputedChunk>,
1062+
props: Object,
1063+
responseState: ResponseState,
1064+
): ReactNodeList {
1065+
const isStylesheet = props.rel === 'stylesheet';
1066+
target.push(startChunkForTag('link'));
1067+
1068+
for (const propKey in props) {
1069+
if (hasOwnProperty.call(props, propKey)) {
1070+
const propValue = props[propKey];
1071+
if (propValue == null) {
1072+
continue;
1073+
}
1074+
switch (propKey) {
1075+
case 'children':
1076+
case 'dangerouslySetInnerHTML':
1077+
throw new Error(
1078+
`${'link'} is a self-closing tag and must neither have \`children\` nor ` +
1079+
'use `dangerouslySetInnerHTML`.',
1080+
);
1081+
case 'precedence': {
1082+
if (isStylesheet) {
1083+
if (propValue === true || typeof propValue === 'string') {
1084+
pushAttribute(target, responseState, 'data-rprec', propValue);
1085+
} else if (__DEV__) {
1086+
throw new Error(
1087+
`the "precedence" prop for links to stylehseets expects to receive a string but received something of type "${typeof propValue}" instead.`,
1088+
);
1089+
}
1090+
break;
1091+
}
1092+
// intentionally fall through
1093+
}
1094+
// eslint-disable-next-line-no-fallthrough
1095+
default:
1096+
pushAttribute(target, responseState, propKey, propValue);
1097+
break;
1098+
}
1099+
}
1100+
}
1101+
1102+
target.push(endOfStartTagSelfClosing);
1103+
return null;
1104+
}
1105+
10591106
function pushSelfClosing(
10601107
target: Array<Chunk | PrecomputedChunk>,
10611108
props: Object,
@@ -1189,6 +1236,39 @@ function pushStartTitle(
11891236
return children;
11901237
}
11911238

1239+
function pushStartHead(
1240+
target: Array<Chunk | PrecomputedChunk>,
1241+
preamble: ?Array<Chunk | PrecomputedChunk>,
1242+
props: Object,
1243+
tag: string,
1244+
responseState: ResponseState,
1245+
): ReactNodeList {
1246+
// Preamble type is nullable for feature off cases but is guaranteed when feature is on
1247+
target = enableFloat ? (preamble: any) : target;
1248+
1249+
return pushStartGenericElement(target, props, tag, responseState);
1250+
}
1251+
1252+
function pushStartHtml(
1253+
target: Array<Chunk | PrecomputedChunk>,
1254+
preamble: ?Array<Chunk | PrecomputedChunk>,
1255+
props: Object,
1256+
tag: string,
1257+
formatContext: FormatContext,
1258+
responseState: ResponseState,
1259+
): ReactNodeList {
1260+
// Preamble type is nullable for feature off cases but is guaranteed when feature is on
1261+
target = enableFloat ? (preamble: any) : target;
1262+
1263+
if (formatContext.insertionMode === ROOT_HTML_MODE) {
1264+
// If we're rendering the html tag and we're at the root (i.e. not in foreignObject)
1265+
// then we also emit the DOCTYPE as part of the root content as a convenience for
1266+
// rendering the whole document.
1267+
target.push(DOCTYPE);
1268+
}
1269+
return pushStartGenericElement(target, props, tag, responseState);
1270+
}
1271+
11921272
function pushStartGenericElement(
11931273
target: Array<Chunk | PrecomputedChunk>,
11941274
props: Object,
@@ -1405,6 +1485,7 @@ const DOCTYPE: PrecomputedChunk = stringToPrecomputedChunk('<!DOCTYPE html>');
14051485

14061486
export function pushStartInstance(
14071487
target: Array<Chunk | PrecomputedChunk>,
1488+
preamble: ?Array<Chunk | PrecomputedChunk>,
14081489
type: string,
14091490
props: Object,
14101491
responseState: ResponseState,
@@ -1461,6 +1542,8 @@ export function pushStartInstance(
14611542
return pushStartMenuItem(target, props, responseState);
14621543
case 'title':
14631544
return pushStartTitle(target, props, responseState);
1545+
case 'link':
1546+
return pushLink(target, props, responseState);
14641547
// Newline eating tags
14651548
case 'listing':
14661549
case 'pre': {
@@ -1475,7 +1558,6 @@ export function pushStartInstance(
14751558
case 'hr':
14761559
case 'img':
14771560
case 'keygen':
1478-
case 'link':
14791561
case 'meta':
14801562
case 'param':
14811563
case 'source':
@@ -1495,14 +1577,18 @@ export function pushStartInstance(
14951577
case 'missing-glyph': {
14961578
return pushStartGenericElement(target, props, type, responseState);
14971579
}
1580+
// Preamble start tags
1581+
case 'head':
1582+
return pushStartHead(target, preamble, props, type, responseState);
14981583
case 'html': {
1499-
if (formatContext.insertionMode === ROOT_HTML_MODE) {
1500-
// If we're rendering the html tag and we're at the root (i.e. not in foreignObject)
1501-
// then we also emit the DOCTYPE as part of the root content as a convenience for
1502-
// rendering the whole document.
1503-
target.push(DOCTYPE);
1504-
}
1505-
return pushStartGenericElement(target, props, type, responseState);
1584+
return pushStartHtml(
1585+
target,
1586+
preamble,
1587+
props,
1588+
type,
1589+
formatContext,
1590+
responseState,
1591+
);
15061592
}
15071593
default: {
15081594
if (type.indexOf('-') === -1 && typeof props.is !== 'string') {
@@ -1521,6 +1607,7 @@ const endTag2 = stringToPrecomputedChunk('>');
15211607

15221608
export function pushEndInstance(
15231609
target: Array<Chunk | PrecomputedChunk>,
1610+
postamble: ?Array<Chunk | PrecomputedChunk>,
15241611
type: string,
15251612
props: Object,
15261613
): void {
@@ -1546,6 +1633,12 @@ export function pushEndInstance(
15461633
// No close tag needed.
15471634
break;
15481635
}
1636+
// Postamble end tags
1637+
case 'body':
1638+
case 'html':
1639+
// Preamble type is nullable for feature off cases but is guaranteed when feature is on
1640+
target = enableFloat ? (postamble: any) : target;
1641+
// Intentional fallthrough
15491642
default: {
15501643
target.push(endTag1, stringToChunk(type), endTag2);
15511644
}

packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js

+2
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ export function pushTextInstance(
137137

138138
export function pushStartInstance(
139139
target: Array<Chunk | PrecomputedChunk>,
140+
preamble: ?Array<Chunk | PrecomputedChunk>,
140141
type: string,
141142
props: Object,
142143
responseState: ResponseState,
@@ -153,6 +154,7 @@ export function pushStartInstance(
153154

154155
export function pushEndInstance(
155156
target: Array<Chunk | PrecomputedChunk>,
157+
postamble: ?Array<Chunk | PrecomputedChunk>,
156158
type: string,
157159
props: Object,
158160
): void {

packages/react-noop-renderer/src/ReactNoopServer.js

+2
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ const ReactNoopServer = ReactFizzServer({
113113
},
114114
pushStartInstance(
115115
target: Array<Uint8Array>,
116+
preamble: Array<Uint8Array>,
116117
type: string,
117118
props: Object,
118119
): ReactNodeList {
@@ -128,6 +129,7 @@ const ReactNoopServer = ReactFizzServer({
128129

129130
pushEndInstance(
130131
target: Array<Uint8Array>,
132+
postamble: Array<Uint8Array>,
131133
type: string,
132134
props: Object,
133135
): void {

packages/react-reconciler/src/ReactFiberHostConfigWithNoHydration.js

+2
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,12 @@ export const supportsHydration = false;
2424
export const canHydrateInstance = shim;
2525
export const canHydrateTextInstance = shim;
2626
export const canHydrateSuspenseInstance = shim;
27+
export const isHydratableResource = shim;
2728
export const isSuspenseInstancePending = shim;
2829
export const isSuspenseInstanceFallback = shim;
2930
export const getSuspenseInstanceFallbackErrorDetails = shim;
3031
export const registerSuspenseInstanceRetry = shim;
32+
export const getMatchingResourceInstance = shim;
3133
export const getNextHydratableSibling = shim;
3234
export const getFirstHydratableChild = shim;
3335
export const getFirstHydratableChildWithinContainer = shim;

0 commit comments

Comments
 (0)