Skip to content

Commit

Permalink
Implement basic stylesheet Resources for react-dom (#25060)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
gnoff committed Aug 12, 2022
1 parent 32baab3 commit 796d318
Show file tree
Hide file tree
Showing 24 changed files with 729 additions and 26 deletions.
404 changes: 402 additions & 2 deletions packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/react-dom/src/__tests__/ReactDOMRoot-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,7 @@ describe('ReactDOMRoot', () => {
);
});

// @gate !__DEV__ || !enableFloat
it('warns if updating a root that has had its contents removed', async () => {
const root = ReactDOMClient.createRoot(container);
root.render(<div>Hi</div>);
Expand Down
14 changes: 13 additions & 1 deletion packages/react-dom/src/client/ReactDOMComponent.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import {
enableTrustedTypesIntegration,
enableCustomElementPropertySupport,
enableClientRenderFallbackOnTextMismatch,
enableFloat,
} from 'shared/ReactFeatureFlags';
import {
mediaEventTypes,
Expand Down Expand Up @@ -257,7 +258,7 @@ export function checkForUnmatchedText(
}
}

function getOwnerDocumentFromRootContainer(
export function getOwnerDocumentFromRootContainer(
rootContainerElement: Element | Document | DocumentFragment,
): Document {
return rootContainerElement.nodeType === DOCUMENT_NODE
Expand Down Expand Up @@ -1018,6 +1019,17 @@ 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 ||
Expand Down
68 changes: 65 additions & 3 deletions packages/react-dom/src/client/ReactDOMHostConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
warnForDeletedHydratableText,
warnForInsertedHydratedElement,
warnForInsertedHydratedText,
getOwnerDocumentFromRootContainer,
} from './ReactDOMComponent';
import {getSelectionInformation, restoreSelection} from './ReactInputSelection';
import setTextContent from './setTextContent';
Expand All @@ -64,6 +65,7 @@ import {retryIfBlockedOn} from '../events/ReactDOMEventReplaying';
import {
enableCreateEventHandleAPI,
enableScopeAPI,
enableFloat,
} from 'shared/ReactFeatureFlags';
import {HostComponent, HostText} from 'react-reconciler/src/ReactWorkTags';
import {listenToAllSupportedEvents} from '../events/DOMPluginEventSystem';
Expand Down Expand Up @@ -675,6 +677,14 @@ export function clearContainer(container: Container): void {

export const supportsHydration = true;

export function isHydratableResource(type: string, props: Props) {
return (
type === 'link' &&
typeof (props: any).precedence === 'string' &&
(props: any).rel === 'stylesheet'
);
}

export function canHydrateInstance(
instance: HydratableInstance,
type: string,
Expand Down Expand Up @@ -769,10 +779,25 @@ export function registerSuspenseInstanceRetry(

function getNextHydratable(node) {
// Skip non-hydratable nodes.
for (; node != null; node = node.nextSibling) {
for (; node != null; node = ((node: any): Node).nextSibling) {
const nodeType = node.nodeType;
if (nodeType === ELEMENT_NODE || nodeType === TEXT_NODE) {
break;
if (enableFloat) {
if (nodeType === ELEMENT_NODE) {
if (
((node: any): Element).tagName === 'LINK' &&
((node: any): Element).hasAttribute('data-rprec')
) {
continue;
}
break;
}
if (nodeType === TEXT_NODE) {
break;
}
} else {
if (nodeType === ELEMENT_NODE || nodeType === TEXT_NODE) {
break;
}
}
if (nodeType === COMMENT_NODE) {
const nodeData = (node: any).data;
Expand Down Expand Up @@ -873,6 +898,43 @@ 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 {
Expand Down
3 changes: 2 additions & 1 deletion packages/react-dom/src/client/ReactDOMRoot.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {

import {queueExplicitHydrationTarget} from '../events/ReactDOMEventReplaying';
import {REACT_ELEMENT_TYPE} from 'shared/ReactSymbols';
import {enableFloat} from 'shared/ReactFeatureFlags';

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

const container = root.containerInfo;

if (container.nodeType !== COMMENT_NODE) {
if (!enableFloat && container.nodeType !== COMMENT_NODE) {
const hostInstance = findHostInstanceWithNoPortals(root.current);
if (hostInstance) {
if (hostInstance.parentNode !== container) {
Expand Down
109 changes: 101 additions & 8 deletions packages/react-dom/src/server/ReactDOMServerFormatConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {Children} from 'react';
import {
enableFilterEmptyStringAttributesDOM,
enableCustomElementPropertySupport,
enableFloat,
} from 'shared/ReactFeatureFlags';

import type {
Expand Down Expand Up @@ -1056,6 +1057,52 @@ function pushStartTextArea(
return null;
}

function pushLink(
target: Array<Chunk | PrecomputedChunk>,
props: Object,
responseState: ResponseState,
): ReactNodeList {
const isStylesheet = props.rel === 'stylesheet';
target.push(startChunkForTag('link'));

for (const propKey in props) {
if (hasOwnProperty.call(props, propKey)) {
const propValue = props[propKey];
if (propValue == null) {
continue;
}
switch (propKey) {
case 'children':
case 'dangerouslySetInnerHTML':
throw new Error(
`${'link'} is a self-closing tag and must neither have \`children\` nor ` +
'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 stylehseets expects to receive a string but received something of type "${typeof propValue}" instead.`,
);
}
break;
}
// intentionally fall through
}
// eslint-disable-next-line-no-fallthrough
default:
pushAttribute(target, responseState, propKey, propValue);
break;
}
}
}

target.push(endOfStartTagSelfClosing);
return null;
}

function pushSelfClosing(
target: Array<Chunk | PrecomputedChunk>,
props: Object,
Expand Down Expand Up @@ -1189,6 +1236,39 @@ function pushStartTitle(
return children;
}

function pushStartHead(
target: Array<Chunk | PrecomputedChunk>,
preamble: ?Array<Chunk | PrecomputedChunk>,
props: Object,
tag: string,
responseState: ResponseState,
): ReactNodeList {
// Preamble type is nullable for feature off cases but is guaranteed when feature is on
target = enableFloat ? (preamble: any) : target;

return pushStartGenericElement(target, props, tag, responseState);
}

function pushStartHtml(
target: Array<Chunk | PrecomputedChunk>,
preamble: ?Array<Chunk | PrecomputedChunk>,
props: Object,
tag: string,
formatContext: FormatContext,
responseState: ResponseState,
): ReactNodeList {
// Preamble type is nullable for feature off cases but is guaranteed when feature is on
target = enableFloat ? (preamble: any) : target;

if (formatContext.insertionMode === ROOT_HTML_MODE) {
// If we're rendering the html tag and we're at the root (i.e. not in foreignObject)
// then we also emit the DOCTYPE as part of the root content as a convenience for
// rendering the whole document.
target.push(DOCTYPE);
}
return pushStartGenericElement(target, props, tag, responseState);
}

function pushStartGenericElement(
target: Array<Chunk | PrecomputedChunk>,
props: Object,
Expand Down Expand Up @@ -1405,6 +1485,7 @@ const DOCTYPE: PrecomputedChunk = stringToPrecomputedChunk('<!DOCTYPE html>');

export function pushStartInstance(
target: Array<Chunk | PrecomputedChunk>,
preamble: ?Array<Chunk | PrecomputedChunk>,
type: string,
props: Object,
responseState: ResponseState,
Expand Down Expand Up @@ -1461,6 +1542,8 @@ export function pushStartInstance(
return pushStartMenuItem(target, props, responseState);
case 'title':
return pushStartTitle(target, props, responseState);
case 'link':
return pushLink(target, props, responseState);
// Newline eating tags
case 'listing':
case 'pre': {
Expand All @@ -1475,7 +1558,6 @@ export function pushStartInstance(
case 'hr':
case 'img':
case 'keygen':
case 'link':
case 'meta':
case 'param':
case 'source':
Expand All @@ -1495,14 +1577,18 @@ export function pushStartInstance(
case 'missing-glyph': {
return pushStartGenericElement(target, props, type, responseState);
}
// Preamble start tags
case 'head':
return pushStartHead(target, preamble, props, type, responseState);
case 'html': {
if (formatContext.insertionMode === ROOT_HTML_MODE) {
// If we're rendering the html tag and we're at the root (i.e. not in foreignObject)
// then we also emit the DOCTYPE as part of the root content as a convenience for
// rendering the whole document.
target.push(DOCTYPE);
}
return pushStartGenericElement(target, props, type, responseState);
return pushStartHtml(
target,
preamble,
props,
type,
formatContext,
responseState,
);
}
default: {
if (type.indexOf('-') === -1 && typeof props.is !== 'string') {
Expand All @@ -1521,6 +1607,7 @@ const endTag2 = stringToPrecomputedChunk('>');

export function pushEndInstance(
target: Array<Chunk | PrecomputedChunk>,
postamble: ?Array<Chunk | PrecomputedChunk>,
type: string,
props: Object,
): void {
Expand All @@ -1546,6 +1633,12 @@ export function pushEndInstance(
// No close tag needed.
break;
}
// Postamble end tags
case 'body':
case 'html':
// Preamble type is nullable for feature off cases but is guaranteed when feature is on
target = enableFloat ? (postamble: any) : target;
// Intentional fallthrough
default: {
target.push(endTag1, stringToChunk(type), endTag2);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ export function pushTextInstance(

export function pushStartInstance(
target: Array<Chunk | PrecomputedChunk>,
preamble: ?Array<Chunk | PrecomputedChunk>,
type: string,
props: Object,
responseState: ResponseState,
Expand All @@ -153,6 +154,7 @@ export function pushStartInstance(

export function pushEndInstance(
target: Array<Chunk | PrecomputedChunk>,
postamble: ?Array<Chunk | PrecomputedChunk>,
type: string,
props: Object,
): void {
Expand Down
2 changes: 2 additions & 0 deletions packages/react-noop-renderer/src/ReactNoopServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ const ReactNoopServer = ReactFizzServer({
},
pushStartInstance(
target: Array<Uint8Array>,
preamble: Array<Uint8Array>,
type: string,
props: Object,
): ReactNodeList {
Expand All @@ -128,6 +129,7 @@ const ReactNoopServer = ReactFizzServer({

pushEndInstance(
target: Array<Uint8Array>,
postamble: Array<Uint8Array>,
type: string,
props: Object,
): void {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,12 @@ 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;
Expand Down
Loading

0 comments on commit 796d318

Please sign in to comment.