Skip to content

Commit 545495d

Browse files
committed
support hydrating resource stylesheets outside of normal flow
1 parent 88402c6 commit 545495d

21 files changed

+344
-24
lines changed

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

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4220,6 +4220,7 @@ describe('ReactDOMFizzServer', () => {
42204220
);
42214221
});
42224222

4223+
// @gate enableFloat
42234224
it('emits html and head start tags (the preamble) before other content if rendered in the shell', async () => {
42244225
await actIntoEmptyDocument(() => {
42254226
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
@@ -4245,7 +4246,7 @@ describe('ReactDOMFizzServer', () => {
42454246
// Hydrate the same thing on the client. We expect this to still fail because <title> is not a Resource
42464247
// and is unmatched on hydration
42474248
const errors = [];
4248-
const root = ReactDOMClient.hydrateRoot(
4249+
ReactDOMClient.hydrateRoot(
42494250
document,
42504251
<>
42514252
<title data-baz="baz">a title</title>
@@ -4280,8 +4281,13 @@ describe('ReactDOMFizzServer', () => {
42804281
'Hydration failed because the initial UI does not match what was rendered on the server.',
42814282
'There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.',
42824283
]);
4284+
expect(getVisibleChildren(document)).toEqual();
4285+
expect(() => {
4286+
expect(Scheduler).toFlushWithoutYielding();
4287+
}).toThrow('The node to be removed is not a child of this node.');
42834288
});
42844289

4290+
// @gate enableFloat
42854291
it('holds back body and html closing tags (the postamble) until all pending tasks are completed', async () => {
42864292
const chunks = [];
42874293
writable.on('data', chunk => {
@@ -4327,6 +4333,119 @@ describe('ReactDOMFizzServer', () => {
43274333
expect(chunks.pop()).toEqual('</body></html>');
43284334
});
43294335

4336+
// @gate enableFloat
4337+
it('recognizes stylesheet links as attributes during hydration', async () => {
4338+
await actIntoEmptyDocument(() => {
4339+
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
4340+
<>
4341+
<link rel="stylesheet" href="foo" precedence="default" />
4342+
<html>
4343+
<head>
4344+
<link rel="author" precedence="this is a nonsense prop" />
4345+
</head>
4346+
<body>a body</body>
4347+
</html>
4348+
</>,
4349+
);
4350+
pipe(writable);
4351+
});
4352+
// precedence for stylesheets is mapped to a valid data attribute that is recognized on the client
4353+
// as opting this node into resource semantics. the use of precedence on the author link is just a
4354+
// non standard attribute which React allows but is not given any special treatment.
4355+
expect(getVisibleChildren(document)).toEqual(
4356+
<html>
4357+
<head>
4358+
<link rel="stylesheet" href="foo" data-rprec="default" />
4359+
<link rel="author" precedence="this is a nonsense prop" />
4360+
</head>
4361+
<body>a body</body>
4362+
</html>,
4363+
);
4364+
4365+
// It hydrates successfully
4366+
ReactDOMClient.hydrateRoot(
4367+
document,
4368+
<>
4369+
<link rel="stylesheet" href="foo" precedence="default" />
4370+
<html>
4371+
<head>
4372+
<link rel="author" precedence="this is a nonsense prop" />
4373+
</head>
4374+
<body>a body</body>
4375+
</html>
4376+
</>,
4377+
);
4378+
expect(Scheduler).toFlushWithoutYielding();
4379+
expect(getVisibleChildren(document)).toEqual(
4380+
<html>
4381+
<head>
4382+
<link rel="stylesheet" href="foo" data-rprec="default" />
4383+
<link rel="author" precedence="this is a nonsense prop" />
4384+
</head>
4385+
<body>a body</body>
4386+
</html>,
4387+
);
4388+
});
4389+
4390+
// @gate __DEV__ && enableFloat
4391+
it('should error in dev when rendering more than one resource for a given location (href)', async () => {
4392+
await actIntoEmptyDocument(() => {
4393+
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
4394+
<>
4395+
<link rel="stylesheet" href="foo" precedence="low" />
4396+
<link rel="stylesheet" href="foo" precedence="high" />
4397+
<html>
4398+
<head />
4399+
<body>a body</body>
4400+
</html>
4401+
</>,
4402+
);
4403+
pipe(writable);
4404+
});
4405+
expect(getVisibleChildren(document)).toEqual(
4406+
<html>
4407+
<head>
4408+
<link rel="stylesheet" href="foo" data-rprec="low" />
4409+
<link rel="stylesheet" href="foo" data-rprec="high" />
4410+
</head>
4411+
<body>a body</body>
4412+
</html>,
4413+
);
4414+
4415+
const errors = [];
4416+
ReactDOMClient.hydrateRoot(
4417+
document,
4418+
<>
4419+
<html>
4420+
<head>
4421+
<link rel="stylesheet" href="foo" precedence="low" />
4422+
<link rel="stylesheet" href="foo" precedence="high" />
4423+
</head>
4424+
<body>a body</body>
4425+
</html>
4426+
</>,
4427+
{
4428+
onRecoverableError(err, errInfo) {
4429+
errors.push(err.message);
4430+
},
4431+
},
4432+
);
4433+
expect(() => {
4434+
expect(Scheduler).toFlushWithoutYielding();
4435+
}).toErrorDev(
4436+
[
4437+
'An error occurred during hydration. The server HTML was replaced with client content in <#document>.',
4438+
],
4439+
{withoutStack: true},
4440+
);
4441+
expect(errors).toEqual([
4442+
'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".',
4443+
'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".',
4444+
'Hydration failed because the initial UI does not match what was rendered on the server.',
4445+
'There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.',
4446+
]);
4447+
});
4448+
43304449
describe('text separators', () => {
43314450
// To force performWork to start before resolving AsyncText but before piping we need to wait until
43324451
// after scheduleWork which currently uses setImmediate to delay performWork

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

Lines changed: 13 additions & 1 deletion
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

Lines changed: 66 additions & 3 deletions
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,44 @@ 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+
throw new 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 "' +
927+
(props: any).href +
928+
'".',
929+
);
930+
}
931+
}
932+
return link;
933+
}
934+
}
935+
}
936+
return null;
937+
}
938+
876939
export function getNextHydratableInstanceAfterSuspenseInstance(
877940
suspenseInstance: SuspenseInstance,
878941
): null | HydratableInstance {

0 commit comments

Comments
 (0)