Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement basic stylesheet Resources for react-dom #25060

Merged
merged 12 commits into from
Aug 12, 2022
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
130 changes: 122 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 @@ -242,6 +243,26 @@ export function getChildFormatContext(
return parentContext;
}

function isPreambleInsertion(type: string): boolean {
switch (type) {
case 'html':
case 'head': {
return true;
}
}
return false;
}

function isPostambleInsertion(type: string): boolean {
switch (type) {
case 'body':
case 'html': {
return true;
}
}
return false;
}

export type SuspenseBoundaryID = null | PrecomputedChunk;

export const UNINITIALIZED_SUSPENSE_BOUNDARY_ID: SuspenseBoundaryID = null;
Expand Down Expand Up @@ -1056,6 +1077,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.`,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

small typo: "stylehseets" -> "stylesheets"

);
}
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 +1256,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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I usually just make the type contain everything you'd expect to exist in the new common state.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was trying to avoid two extra array allocations for non dom (even though no one else is really using it yet I suppose). In the future I'd expect that preamble is not nullable for dom fizz server and is explicitly null for environments that do not support preamble but how would I type that? I guess I could export the preamble type but then I'd probably also need a create function for the preamble itself.

Do you recommend doing it that way?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It'd just make it required. It's at most two per request, and we don't know if other renderers will end up using it yet.

target = enableFloat && isPreambleInsertion(tag) ? (preamble: any) : target;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You don't need the isPreambleInsertion() checks. That was the point of creating the pushStartHead helper - now you know that it's a preamble insertion.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

brain broke


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 && isPreambleInsertion(tag) ? (preamble: any) : target;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here.


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 +1505,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 +1562,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 +1578,6 @@ export function pushStartInstance(
case 'hr':
case 'img':
case 'keygen':
case 'link':
case 'meta':
case 'param':
case 'source':
Expand All @@ -1495,14 +1597,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 +1627,7 @@ const endTag2 = stringToPrecomputedChunk('>');

export function pushEndInstance(
target: Array<Chunk | PrecomputedChunk>,
postamble: ?Array<Chunk | PrecomputedChunk>,
type: string,
props: Object,
): void {
Expand All @@ -1546,6 +1653,13 @@ 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 && isPostambleInsertion(type) ? (postamble: any) : target;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here.

// 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
Loading