@samitouri / QOS-React-2 / commits / 796d31809b

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.

Josh Story committed Aug 12, 2022 at 13:27 UTC 796d31809b3683083d3b62ccbab4f00dec8ffb1f
24 files changed +729 -26
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+402 -2
@@ -128,6 +128,8 @@ describe('ReactDOMFizzServer', () => {
128 buffer = '';
129 const fakeBody = document.createElement('body');
130 fakeBody.innerHTML = bufferedContent;
131 + const parent =
132 + container.nodeName === '#document' ? container.body : container;
133 while (fakeBody.firstChild) {
134 const node = fakeBody.firstChild;
135 if (
@@ -137,13 +139,37 @@ describe('ReactDOMFizzServer', () => {
139 const script = document.createElement('script');
140 script.textContent = node.textContent;
141 fakeBody.removeChild(node);
140 - container.appendChild(script);
142 + parent.appendChild(script);
143 } else {
142 - container.appendChild(node);
144 + parent.appendChild(node);
145 }
146 }
147 }
148
149 + async function actIntoEmptyDocument(callback) {
150 + await callback();
151 + // Await one turn around the event loop.
152 + // This assumes that we'll flush everything we have so far.
153 + await new Promise(resolve => {
154 + setImmediate(resolve);
155 + });
156 + if (hasErrored) {
157 + throw fatalError;
158 + }
159 + // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment.
160 + // We also want to execute any scripts that are embedded.
161 + // We assume that we have now received a proper fragment of HTML.
162 + const bufferedContent = buffer;
163 + // Test Environment
164 + const jsdom = new JSDOM(bufferedContent, {
165 + runScripts: 'dangerously',
166 + });
167 + window = jsdom.window;
168 + document = jsdom.window.document;
169 + container = document;
170 + buffer = '';
171 + }
172 +
173 function getVisibleChildren(element) {
174 const children = [];
175 let node = element.firstChild;
@@ -4194,6 +4220,380 @@ describe('ReactDOMFizzServer', () => {
4220 );
4221 });
4222
4223 + // @gate enableFloat
4224 + it('emits html and head start tags (the preamble) before other content if rendered in the shell', async () => {
4225 + await actIntoEmptyDocument(() => {
4226 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
4227 + <>
4228 + <title data-baz="baz">a title</title>
4229 + <html data-foo="foo">
4230 + <head data-bar="bar" />
4231 + <body>a body</body>
4232 + </html>
4233 + </>,
4234 + );
4235 + pipe(writable);
4236 + });
4237 + expect(getVisibleChildren(document)).toEqual(
4238 + <html data-foo="foo">
4239 + <head data-bar="bar">
4240 + <title data-baz="baz">a title</title>
4241 + </head>
4242 + <body>a body</body>
4243 + </html>,
4244 + );
4245 +
4246 + // Hydrate the same thing on the client. We expect this to still fail because <title> is not a Resource
4247 + // and is unmatched on hydration
4248 + const errors = [];
4249 + ReactDOMClient.hydrateRoot(
4250 + document,
4251 + <>
4252 + <title data-baz="baz">a title</title>
4253 + <html data-foo="foo">
4254 + <head data-bar="bar" />
4255 + <body>a body</body>
4256 + </html>
4257 + </>,
4258 + {
4259 + onRecoverableError: (err, errInfo) => {
4260 + errors.push(err.message);
4261 + },
4262 + },
4263 + );
4264 + expect(() => {
4265 + try {
4266 + expect(() => {
4267 + expect(Scheduler).toFlushWithoutYielding();
4268 + }).toThrow('Invalid insertion of HTML node in #document node.');
4269 + } catch (e) {
4270 + console.log('e', e);
4271 + }
4272 + }).toErrorDev(
4273 + [
4274 + 'Warning: Expected server HTML to contain a matching <title> in <#document>.',
4275 + 'Warning: An error occurred during hydration. The server HTML was replaced with client content in <#document>.',
4276 + 'Warning: validateDOMNesting(...): <title> cannot appear as a child of <#document>',
4277 + ],
4278 + {withoutStack: 1},
4279 + );
4280 + expect(errors).toEqual([
4281 + 'Hydration failed because the initial UI does not match what was rendered on the server.',
4282 + 'There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.',
4283 + ]);
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.');
4288 + });
4289 +
4290 + // @gate enableFloat
4291 + it('holds back body and html closing tags (the postamble) until all pending tasks are completed', async () => {
4292 + const chunks = [];
4293 + writable.on('data', chunk => {
4294 + chunks.push(chunk);
4295 + });
4296 +
4297 + await actIntoEmptyDocument(() => {
4298 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
4299 + <html>
4300 + <head />
4301 + <body>
4302 + first
4303 + <Suspense>
4304 + <AsyncText text="second" />
4305 + </Suspense>
4306 + </body>
4307 + </html>,
4308 + );
4309 + pipe(writable);
4310 + });
4311 +
4312 + expect(getVisibleChildren(document)).toEqual(
4313 + <html>
4314 + <head />
4315 + <body>{'first'}</body>
4316 + </html>,
4317 + );
4318 +
4319 + await act(() => {
4320 + resolveText('second');
4321 + });
4322 +
4323 + expect(getVisibleChildren(document)).toEqual(
4324 + <html>
4325 + <head />
4326 + <body>
4327 + {'first'}
4328 + {'second'}
4329 + </body>
4330 + </html>,
4331 + );
4332 +
4333 + expect(chunks.pop()).toEqual('</body></html>');
4334 + });
4335 +
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 + const root = 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 + // We manually capture uncaught errors b/c Jest does not play well with errors thrown in
4379 + // microtasks after the test completes even when it is expecting to fail (e.g. when the gate is false)
4380 + // We need to flush the scheduler at the end even if there was an earlier throw otherwise this test will
4381 + // fail even when failure is expected. This is primarily caused by invokeGuardedCallback replaying commit
4382 + // phase errors which get rethrown in a microtask
4383 + const uncaughtErrors = [];
4384 + try {
4385 + expect(Scheduler).toFlushWithoutYielding();
4386 + expect(getVisibleChildren(document)).toEqual(
4387 + <html>
4388 + <head>
4389 + <link rel="stylesheet" href="foo" data-rprec="default" />
4390 + <link rel="author" precedence="this is a nonsense prop" />
4391 + </head>
4392 + <body>a body</body>
4393 + </html>,
4394 + );
4395 + } catch (e) {
4396 + uncaughtErrors.push(e);
4397 + }
4398 + try {
4399 + expect(Scheduler).toFlushWithoutYielding();
4400 + } catch (e) {
4401 + uncaughtErrors.push(e);
4402 + }
4403 +
4404 + root.render(
4405 + <>
4406 + <link rel="stylesheet" href="foo" precedence="default" data-bar="bar" />
4407 + <html>
4408 + <head />
4409 + <body>a body</body>
4410 + </html>
4411 + </>,
4412 + );
4413 + try {
4414 + expect(Scheduler).toFlushWithoutYielding();
4415 + expect(getVisibleChildren(document)).toEqual(
4416 + <html>
4417 + <head>
4418 + <link
4419 + rel="stylesheet"
4420 + href="foo"
4421 + data-rprec="default"
4422 + data-bar="bar"
4423 + />
4424 + </head>
4425 + <body>a body</body>
4426 + </html>,
4427 + );
4428 + } catch (e) {
4429 + uncaughtErrors.push(e);
4430 + }
4431 + try {
4432 + expect(Scheduler).toFlushWithoutYielding();
4433 + } catch (e) {
4434 + uncaughtErrors.push(e);
4435 + }
4436 +
4437 + if (uncaughtErrors.length > 0) {
4438 + throw uncaughtErrors[0];
4439 + }
4440 + });
4441 +
4442 + // Temporarily this test is expected to fail everywhere. When we have resource hoisting
4443 + // it should start to pass and we can adjust the gate accordingly
4444 + // @gate false && enableFloat
4445 + it('should insert missing resources during hydration', async () => {
4446 + await actIntoEmptyDocument(() => {
4447 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
4448 + <html>
4449 + <body>foo</body>
4450 + </html>,
4451 + );
4452 + pipe(writable);
4453 + });
4454 +
4455 + const uncaughtErrors = [];
4456 + ReactDOMClient.hydrateRoot(
4457 + document,
4458 + <>
4459 + <link rel="stylesheet" href="foo" precedence="foo" />
4460 + <html>
4461 + <head />
4462 + <body>foo</body>
4463 + </html>
4464 + </>,
4465 + );
4466 + try {
4467 + expect(Scheduler).toFlushWithoutYielding();
4468 + expect(getVisibleChildren(document)).toEqual(
4469 + <html>
4470 + <head>
4471 + <link rel="stylesheet" href="foo" precedence="foo" />
4472 + </head>
4473 + <body>foo</body>
4474 + </html>,
4475 + );
4476 + } catch (e) {
4477 + uncaughtErrors.push(e);
4478 + }
4479 +
4480 + // need to flush again to get the invoke guarded callback error to throw in microtask
4481 + try {
4482 + expect(Scheduler).toFlushWithoutYielding();
4483 + } catch (e) {
4484 + uncaughtErrors.push(e);
4485 + }
4486 +
4487 + if (uncaughtErrors.length) {
4488 + throw uncaughtErrors[0];
4489 + }
4490 + });
4491 +
4492 + // @gate experimental && enableFloat
4493 + it('fail hydration if a suitable resource cannot be found in the DOM for a given location (href)', async () => {
4494 + gate(flags => {
4495 + if (!(__EXPERIMENTAL__ && flags.enableFloat)) {
4496 + throw new Error('bailing out of test');
4497 + }
4498 + });
4499 + await actIntoEmptyDocument(() => {
4500 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
4501 + <html>
4502 + <head />
4503 + <body>a body</body>
4504 + </html>,
4505 + );
4506 + pipe(writable);
4507 + });
4508 +
4509 + const errors = [];
4510 + ReactDOMClient.hydrateRoot(
4511 + document,
4512 + <html>
4513 + <head>
4514 + <link rel="stylesheet" href="foo" precedence="low" />
4515 + </head>
4516 + <body>a body</body>
4517 + </html>,
4518 + {
4519 + onRecoverableError(err, errInfo) {
4520 + errors.push(err.message);
4521 + },
4522 + },
4523 + );
4524 + expect(() => {
4525 + expect(Scheduler).toFlushWithoutYielding();
4526 + }).toErrorDev(
4527 + [
4528 + 'Warning: A matching Hydratable Resource was not found in the DOM for <link rel="stylesheet" href="foo">',
4529 + 'Warning: An error occurred during hydration. The server HTML was replaced with client content in <#document>.',
4530 + ],
4531 + {withoutStack: 1},
4532 + );
4533 + expect(errors).toEqual([
4534 + 'Hydration failed because the initial UI does not match what was rendered on the server.',
4535 + 'Hydration failed because the initial UI does not match what was rendered on the server.',
4536 + 'There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.',
4537 + ]);
4538 + });
4539 +
4540 + // @gate experimental && enableFloat
4541 + it('should error in dev when rendering more than one resource for a given location (href)', async () => {
4542 + gate(flags => {
4543 + if (!(__EXPERIMENTAL__ && flags.enableFloat)) {
4544 + throw new Error('bailing out of test');
4545 + }
4546 + });
4547 + await actIntoEmptyDocument(() => {
4548 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
4549 + <>
4550 + <link rel="stylesheet" href="foo" precedence="low" />
4551 + <link rel="stylesheet" href="foo" precedence="high" />
4552 + <html>
4553 + <head />
4554 + <body>a body</body>
4555 + </html>
4556 + </>,
4557 + );
4558 + pipe(writable);
4559 + });
4560 + expect(getVisibleChildren(document)).toEqual(
4561 + <html>
4562 + <head>
4563 + <link rel="stylesheet" href="foo" data-rprec="low" />
4564 + <link rel="stylesheet" href="foo" data-rprec="high" />
4565 + </head>
4566 + <body>a body</body>
4567 + </html>,
4568 + );
4569 +
4570 + const errors = [];
4571 + ReactDOMClient.hydrateRoot(
4572 + document,
4573 + <>
4574 + <html>
4575 + <head>
4576 + <link rel="stylesheet" href="foo" precedence="low" />
4577 + <link rel="stylesheet" href="foo" precedence="high" />
4578 + </head>
4579 + <body>a body</body>
4580 + </html>
4581 + </>,
4582 + {
4583 + onRecoverableError(err, errInfo) {
4584 + errors.push(err.message);
4585 + },
4586 + },
4587 + );
4588 + expect(() => {
4589 + expect(Scheduler).toFlushWithoutYielding();
4590 + }).toErrorDev([
4591 + 'Warning: 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"',
4592 + 'Warning: 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"',
4593 + ]);
4594 + expect(errors).toEqual([]);
4595 + });
4596 +
4597 describe('text separators', () => {
4598 // To force performWork to start before resolving AsyncText but before piping we need to wait until
4599 // after scheduleWork which currently uses setImmediate to delay performWork
packages/react-dom/src/__tests__/ReactDOMRoot-test.js
+1
@@ -358,6 +358,7 @@ describe('ReactDOMRoot', () => {
358 );
359 });
360
361 + // @gate !__DEV__ || !enableFloat
362 it('warns if updating a root that has had its contents removed', async () => {
363 const root = ReactDOMClient.createRoot(container);
364 root.render(<div>Hi</div>);
packages/react-dom/src/client/ReactDOMComponent.js
+13 -1
@@ -73,6 +73,7 @@ import {
73 enableTrustedTypesIntegration,
74 enableCustomElementPropertySupport,
75 enableClientRenderFallbackOnTextMismatch,
76 + enableFloat,
77 } from 'shared/ReactFeatureFlags';
78 import {
79 mediaEventTypes,
@@ -257,7 +258,7 @@ export function checkForUnmatchedText(
258 }
259 }
260
260 -function getOwnerDocumentFromRootContainer(
261 +export function getOwnerDocumentFromRootContainer(
262 rootContainerElement: Element | Document | DocumentFragment,
263 ): Document {
264 return rootContainerElement.nodeType === DOCUMENT_NODE
@@ -1018,6 +1019,17 @@ export function diffHydratedProperties(
1019 : getPropertyInfo(propKey);
1020 if (rawProps[SUPPRESS_HYDRATION_WARNING] === true) {
1021 // 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');
1033 } else if (
1034 propKey === SUPPRESS_CONTENT_EDITABLE_WARNING ||
1035 propKey === SUPPRESS_HYDRATION_WARNING ||
packages/react-dom/src/client/ReactDOMHostConfig.js
+65 -3
@@ -40,6 +40,7 @@ import {
40 warnForDeletedHydratableText,
41 warnForInsertedHydratedElement,
42 warnForInsertedHydratedText,
43 + getOwnerDocumentFromRootContainer,
44 } from './ReactDOMComponent';
45 import {getSelectionInformation, restoreSelection} from './ReactInputSelection';
46 import setTextContent from './setTextContent';
@@ -64,6 +65,7 @@ import {retryIfBlockedOn} from '../events/ReactDOMEventReplaying';
65 import {
66 enableCreateEventHandleAPI,
67 enableScopeAPI,
68 + enableFloat,
69 } from 'shared/ReactFeatureFlags';
70 import {HostComponent, HostText} from 'react-reconciler/src/ReactWorkTags';
71 import {listenToAllSupportedEvents} from '../events/DOMPluginEventSystem';
@@ -675,6 +677,14 @@ export function clearContainer(container: Container): void {
677
678 export const supportsHydration = true;
679
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 +
688 export function canHydrateInstance(
689 instance: HydratableInstance,
690 type: string,
@@ -769,10 +779,25 @@ export function registerSuspenseInstanceRetry(
779
780 function getNextHydratable(node) {
781 // Skip non-hydratable nodes.
772 - for (; node != null; node = node.nextSibling) {
782 + for (; node != null; node = ((node: any): Node).nextSibling) {
783 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 + }
801 }
802 if (nodeType === COMMENT_NODE) {
803 const nodeData = (node: any).data;
@@ -873,6 +898,43 @@ export function hydrateSuspenseInstance(
898 precacheFiberNode(internalInstanceHandle, suspenseInstance);
899 }
900
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 +
938 export function getNextHydratableInstanceAfterSuspenseInstance(
939 suspenseInstance: SuspenseInstance,
940 ): null | HydratableInstance {
packages/react-dom/src/client/ReactDOMRoot.js
+2 -1
@@ -15,6 +15,7 @@ import type {
15
16 import {queueExplicitHydrationTarget} from '../events/ReactDOMEventReplaying';
17 import {REACT_ELEMENT_TYPE} from 'shared/ReactSymbols';
18 +import {enableFloat} from 'shared/ReactFeatureFlags';
19
20 export type RootType = {
21 render(children: ReactNodeList): void,
@@ -118,7 +119,7 @@ ReactDOMHydrationRoot.prototype.render = ReactDOMRoot.prototype.render = functio
119
120 const container = root.containerInfo;
121
121 - if (container.nodeType !== COMMENT_NODE) {
122 + if (!enableFloat && container.nodeType !== COMMENT_NODE) {
123 const hostInstance = findHostInstanceWithNoPortals(root.current);
124 if (hostInstance) {
125 if (hostInstance.parentNode !== container) {
packages/react-dom/src/server/ReactDOMServerFormatConfig.js
+101 -8
@@ -20,6 +20,7 @@ import {Children} from 'react';
20 import {
21 enableFilterEmptyStringAttributesDOM,
22 enableCustomElementPropertySupport,
23 + enableFloat,
24 } from 'shared/ReactFeatureFlags';
25
26 import type {
@@ -1056,6 +1057,52 @@ function pushStartTextArea(
1057 return null;
1058 }
1059
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 +
1106 function pushSelfClosing(
1107 target: Array<Chunk | PrecomputedChunk>,
1108 props: Object,
@@ -1189,6 +1236,39 @@ function pushStartTitle(
1236 return children;
1237 }
1238
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 +
1272 function pushStartGenericElement(
1273 target: Array<Chunk | PrecomputedChunk>,
1274 props: Object,
@@ -1405,6 +1485,7 @@ const DOCTYPE: PrecomputedChunk = stringToPrecomputedChunk('<!DOCTYPE html>');
1485
1486 export function pushStartInstance(
1487 target: Array<Chunk | PrecomputedChunk>,
1488 + preamble: ?Array<Chunk | PrecomputedChunk>,
1489 type: string,
1490 props: Object,
1491 responseState: ResponseState,
@@ -1461,6 +1542,8 @@ export function pushStartInstance(
1542 return pushStartMenuItem(target, props, responseState);
1543 case 'title':
1544 return pushStartTitle(target, props, responseState);
1545 + case 'link':
1546 + return pushLink(target, props, responseState);
1547 // Newline eating tags
1548 case 'listing':
1549 case 'pre': {
@@ -1475,7 +1558,6 @@ export function pushStartInstance(
1558 case 'hr':
1559 case 'img':
1560 case 'keygen':
1478 - case 'link':
1561 case 'meta':
1562 case 'param':
1563 case 'source':
@@ -1495,14 +1577,18 @@ export function pushStartInstance(
1577 case 'missing-glyph': {
1578 return pushStartGenericElement(target, props, type, responseState);
1579 }
1580 + // Preamble start tags
1581 + case 'head':
1582 + return pushStartHead(target, preamble, props, type, responseState);
1583 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 + );
1592 }
1593 default: {
1594 if (type.indexOf('-') === -1 && typeof props.is !== 'string') {
@@ -1521,6 +1607,7 @@ const endTag2 = stringToPrecomputedChunk('>');
1607
1608 export function pushEndInstance(
1609 target: Array<Chunk | PrecomputedChunk>,
1610 + postamble: ?Array<Chunk | PrecomputedChunk>,
1611 type: string,
1612 props: Object,
1613 ): void {
@@ -1546,6 +1633,12 @@ export function pushEndInstance(
1633 // No close tag needed.
1634 break;
1635 }
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
1642 default: {
1643 target.push(endTag1, stringToChunk(type), endTag2);
1644 }
packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js
+2
@@ -137,6 +137,7 @@ export function pushTextInstance(
137
138 export function pushStartInstance(
139 target: Array<Chunk | PrecomputedChunk>,
140 + preamble: ?Array<Chunk | PrecomputedChunk>,
141 type: string,
142 props: Object,
143 responseState: ResponseState,
@@ -153,6 +154,7 @@ export function pushStartInstance(
154
155 export function pushEndInstance(
156 target: Array<Chunk | PrecomputedChunk>,
157 + postamble: ?Array<Chunk | PrecomputedChunk>,
158 type: string,
159 props: Object,
160 ): void {
packages/react-noop-renderer/src/ReactNoopServer.js
+2
@@ -113,6 +113,7 @@ const ReactNoopServer = ReactFizzServer({
113 },
114 pushStartInstance(
115 target: Array<Uint8Array>,
116 + preamble: Array<Uint8Array>,
117 type: string,
118 props: Object,
119 ): ReactNodeList {
@@ -128,6 +129,7 @@ const ReactNoopServer = ReactFizzServer({
129
130 pushEndInstance(
131 target: Array<Uint8Array>,
132 + postamble: Array<Uint8Array>,
133 type: string,
134 props: Object,
135 ): void {
packages/react-reconciler/src/ReactFiberHostConfigWithNoHydration.js
+2
@@ -24,10 +24,12 @@ export const supportsHydration = false;
24 export const canHydrateInstance = shim;
25 export const canHydrateTextInstance = shim;
26 export const canHydrateSuspenseInstance = shim;
27 +export const isHydratableResource = shim;
28 export const isSuspenseInstancePending = shim;
29 export const isSuspenseInstanceFallback = shim;
30 export const getSuspenseInstanceFallbackErrorDetails = shim;
31 export const registerSuspenseInstanceRetry = shim;
32 +export const getMatchingResourceInstance = shim;
33 export const getNextHydratableSibling = shim;
34 export const getFirstHydratableChild = shim;
35 export const getFirstHydratableChildWithinContainer = shim;
packages/react-reconciler/src/ReactFiberHydrationContext.new.js
+41
@@ -34,6 +34,7 @@ import {
34 NoFlags,
35 DidCapture,
36 } from './ReactFiberFlags';
37 +import {enableFloat} from 'shared/ReactFeatureFlags';
38
39 import {
40 createFiberFromHostInstanceForDeletion,
@@ -45,7 +46,9 @@ import {
46 canHydrateInstance,
47 canHydrateTextInstance,
48 canHydrateSuspenseInstance,
49 + isHydratableResource,
50 getNextHydratableSibling,
51 + getMatchingResourceInstance,
52 getFirstHydratableChild,
53 getFirstHydratableChildWithinContainer,
54 getFirstHydratableChildWithinSuspenseInstance,
@@ -75,6 +78,7 @@ import {
78 restoreSuspendedTreeContext,
79 } from './ReactFiberTreeContext.new';
80 import {queueRecoverableErrors} from './ReactFiberWorkLoop.new';
81 +import {getRootHostContainer} from './ReactFiberHostContext.new';
82
83 // The deepest Fiber on the stack involved in a hydration context.
84 // This may have been an insertion or a hydration.
@@ -404,6 +408,19 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void {
408 if (!isHydrating) {
409 return;
410 }
411 + if (enableFloat) {
412 + if (
413 + fiber.tag === HostComponent &&
414 + isHydratableResource(fiber.type, fiber.pendingProps)
415 + ) {
416 + fiber.stateNode = getMatchingResourceInstance(
417 + fiber.type,
418 + fiber.pendingProps,
419 + getRootHostContainer(),
420 + );
421 + return;
422 + }
423 + }
424 let nextInstance = nextHydratableInstance;
425 if (!nextInstance) {
426 if (shouldClientRenderOnMismatch(fiber)) {
@@ -596,6 +613,30 @@ function popHydrationState(fiber: Fiber): boolean {
613 if (!supportsHydration) {
614 return false;
615 }
616 + if (
617 + enableFloat &&
618 + isHydrating &&
619 + isHydratableResource(fiber.type, fiber.memoizedProps)
620 + ) {
621 + if (fiber.stateNode === null) {
622 + if (__DEV__) {
623 + const rel = fiber.memoizedProps.rel
624 + ? `rel="${fiber.memoizedProps.rel}" `
625 + : '';
626 + const href = fiber.memoizedProps.href
627 + ? `href="${fiber.memoizedProps.href}"`
628 + : '';
629 + console.error(
630 + 'A matching Hydratable Resource was not found in the DOM for <%s %s%s>.',
631 + fiber.type,
632 + rel,
633 + href,
634 + );
635 + }
636 + throwOnHydrationMismatch(fiber);
637 + }
638 + return true;
639 + }
640 if (fiber !== hydrationParentFiber) {
641 // We're deeper than the current hydration context, inside an inserted
642 // tree.
packages/react-reconciler/src/ReactFiberHydrationContext.old.js
+41
@@ -34,6 +34,7 @@ import {
34 NoFlags,
35 DidCapture,
36 } from './ReactFiberFlags';
37 +import {enableFloat} from 'shared/ReactFeatureFlags';
38
39 import {
40 createFiberFromHostInstanceForDeletion,
@@ -45,7 +46,9 @@ import {
46 canHydrateInstance,
47 canHydrateTextInstance,
48 canHydrateSuspenseInstance,
49 + isHydratableResource,
50 getNextHydratableSibling,
51 + getMatchingResourceInstance,
52 getFirstHydratableChild,
53 getFirstHydratableChildWithinContainer,
54 getFirstHydratableChildWithinSuspenseInstance,
@@ -75,6 +78,7 @@ import {
78 restoreSuspendedTreeContext,
79 } from './ReactFiberTreeContext.old';
80 import {queueRecoverableErrors} from './ReactFiberWorkLoop.old';
81 +import {getRootHostContainer} from './ReactFiberHostContext.old';
82
83 // The deepest Fiber on the stack involved in a hydration context.
84 // This may have been an insertion or a hydration.
@@ -404,6 +408,19 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void {
408 if (!isHydrating) {
409 return;
410 }
411 + if (enableFloat) {
412 + if (
413 + fiber.tag === HostComponent &&
414 + isHydratableResource(fiber.type, fiber.pendingProps)
415 + ) {
416 + fiber.stateNode = getMatchingResourceInstance(
417 + fiber.type,
418 + fiber.pendingProps,
419 + getRootHostContainer(),
420 + );
421 + return;
422 + }
423 + }
424 let nextInstance = nextHydratableInstance;
425 if (!nextInstance) {
426 if (shouldClientRenderOnMismatch(fiber)) {
@@ -596,6 +613,30 @@ function popHydrationState(fiber: Fiber): boolean {
613 if (!supportsHydration) {
614 return false;
615 }
616 + if (
617 + enableFloat &&
618 + isHydrating &&
619 + isHydratableResource(fiber.type, fiber.memoizedProps)
620 + ) {
621 + if (fiber.stateNode === null) {
622 + if (__DEV__) {
623 + const rel = fiber.memoizedProps.rel
624 + ? `rel="${fiber.memoizedProps.rel}" `
625 + : '';
626 + const href = fiber.memoizedProps.href
627 + ? `href="${fiber.memoizedProps.href}"`
628 + : '';
629 + console.error(
630 + 'A matching Hydratable Resource was not found in the DOM for <%s %s%s>.',
631 + fiber.type,
632 + rel,
633 + href,
634 + );
635 + }
636 + throwOnHydrationMismatch(fiber);
637 + }
638 + return true;
639 + }
640 if (fiber !== hydrationParentFiber) {
641 // We're deeper than the current hydration context, inside an inserted
642 // tree.
packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js
+3
@@ -186,3 +186,6 @@ export const didNotFindHydratableTextInstance =
186 export const didNotFindHydratableSuspenseInstance =
187 $$$hostConfig.didNotFindHydratableSuspenseInstance;
188 export const errorHydratingContainer = $$$hostConfig.errorHydratingContainer;
189 +export const isHydratableResource = $$$hostConfig.isHydratableResource;
190 +export const getMatchingResourceInstance =
191 + $$$hostConfig.getMatchingResourceInstance;
packages/react-server/src/ReactFizzServer.js
+40 -10
@@ -117,6 +117,7 @@ import {
117 warnAboutDefaultPropsOnFunctionComponents,
118 enableScopeAPI,
119 enableSuspenseAvoidThisFallbackFizz,
120 + enableFloat,
121 } from 'shared/ReactFeatureFlags';
122
123 import assign from 'shared/assign';
@@ -200,6 +201,8 @@ export opaque type Request = {
201 clientRenderedBoundaries: Array<SuspenseBoundary>, // Errored or client rendered but not yet flushed.
202 completedBoundaries: Array<SuspenseBoundary>, // Completed but not yet fully flushed boundaries to show.
203 partialBoundaries: Array<SuspenseBoundary>, // Partially completed boundaries that can flush its segments early.
204 + +preamble: ?Array<Chunk | PrecomputedChunk>, // Chunks that need to be emitted before any segment chunks.
205 + +postamble: ?Array<Chunk | PrecomputedChunk>, // Chunks that need to be emitted after segments, waiting for all pending root tasks to finish
206 // onError is called when an error happens anywhere in the tree. It might recover.
207 // The return string is used in production primarily to avoid leaking internals, secondarily to save bytes.
208 // Returning null/undefined will cause a defualt error message in production
@@ -272,6 +275,8 @@ export function createRequest(
275 clientRenderedBoundaries: [],
276 completedBoundaries: [],
277 partialBoundaries: [],
278 + preamble: enableFloat ? [] : null,
279 + postamble: enableFloat ? [] : null,
280 onError: onError === undefined ? defaultErrorHandler : onError,
281 onAllReady: onAllReady === undefined ? noop : onAllReady,
282 onShellReady: onShellReady === undefined ? noop : onShellReady,
@@ -632,6 +637,7 @@ function renderHostElement(
637 const segment = task.blockedSegment;
638 const children = pushStartInstance(
639 segment.chunks,
640 + request.preamble,
641 type,
642 props,
643 request.responseState,
@@ -647,7 +653,7 @@ function renderHostElement(
653 // We expect that errors will fatal the whole task and that we don't need
654 // the correct context. Therefore this is not in a finally.
655 segment.formatContext = prevContext;
650 - pushEndInstance(segment.chunks, type, props);
656 + pushEndInstance(segment.chunks, request.postamble, type, props);
657 segment.lastPushedText = false;
658 popComponentStackInDEV(task);
659 }
@@ -2063,20 +2069,33 @@ function flushCompletedQueues(
2069
2070 // TODO: Emit preloading.
2071
2066 - // TODO: It's kind of unfortunate to keep checking this array after we've already
2067 - // emitted the root.
2072 + let i;
2073 const completedRootSegment = request.completedRootSegment;
2069 - if (completedRootSegment !== null && request.pendingRootTasks === 0) {
2070 - flushSegment(request, destination, completedRootSegment);
2071 - request.completedRootSegment = null;
2072 - writeCompletedRoot(destination, request.responseState);
2074 + if (completedRootSegment !== null) {
2075 + if (request.pendingRootTasks === 0) {
2076 + if (enableFloat) {
2077 + const preamble: Array<
2078 + Chunk | PrecomputedChunk,
2079 + > = (request.preamble: any);
2080 + for (i = 0; i < preamble.length; i++) {
2081 + // we expect the preamble to be tiny and will ignore backpressure
2082 + writeChunk(destination, preamble[i]);
2083 + }
2084 + }
2085 +
2086 + flushSegment(request, destination, completedRootSegment);
2087 + request.completedRootSegment = null;
2088 + writeCompletedRoot(destination, request.responseState);
2089 + } else {
2090 + // We haven't flushed the root yet so we don't need to check boundaries further down
2091 + return;
2092 + }
2093 }
2094
2095 // We emit client rendering instructions for already emitted boundaries first.
2096 // This is so that we can signal to the client to start client rendering them as
2097 // soon as possible.
2098 const clientRenderedBoundaries = request.clientRenderedBoundaries;
2079 - let i;
2099 for (i = 0; i < clientRenderedBoundaries.length; i++) {
2100 const boundary = clientRenderedBoundaries[i];
2101 if (!flushClientRenderedBoundary(request, destination, boundary)) {
@@ -2139,8 +2158,6 @@ function flushCompletedQueues(
2158 }
2159 largeBoundaries.splice(0, i);
2160 } finally {
2142 - completeWriting(destination);
2143 - flushBuffered(destination);
2161 if (
2162 request.allPendingTasks === 0 &&
2163 request.pingedTasks.length === 0 &&
@@ -2149,6 +2166,16 @@ function flushCompletedQueues(
2166 // We don't need to check any partially completed segments because
2167 // either they have pending task or they're complete.
2168 ) {
2169 + if (enableFloat) {
2170 + const postamble: Array<
2171 + Chunk | PrecomputedChunk,
2172 + > = (request.postamble: any);
2173 + for (let i = 0; i < postamble.length; i++) {
2174 + writeChunk(destination, postamble[i]);
2175 + }
2176 + }
2177 + completeWriting(destination);
2178 + flushBuffered(destination);
2179 if (__DEV__) {
2180 if (request.abortableTasks.size !== 0) {
2181 console.error(
@@ -2158,6 +2185,9 @@ function flushCompletedQueues(
2185 }
2186 // We're done.
2187 close(destination);
2188 + } else {
2189 + completeWriting(destination);
2190 + flushBuffered(destination);
2191 }
2192 }
2193 }
packages/shared/ReactFeatureFlags.js
+2
@@ -116,6 +116,8 @@ export const enableCPUSuspense = __EXPERIMENTAL__;
116 // aggressiveness.
117 export const deletedTreeCleanUpLevel = 3;
118
119 +export const enableFloat = __EXPERIMENTAL__;
120 +
121 // -----------------------------------------------------------------------------
122 // Chopping Block
123 //
packages/shared/forks/ReactFeatureFlags.native-fb.js
+1
@@ -81,6 +81,7 @@ export const enableUseMutableSource = true;
81 export const enableTransitionTracing = false;
82 export const enableSymbolFallbackForWWW = false;
83
84 +export const enableFloat = false;
85 // Flow magic to verify the exports of this file match the original version.
86 // eslint-disable-next-line no-unused-vars
87 type Check<_X, Y: _X, X: Y = _X> = null;
packages/shared/forks/ReactFeatureFlags.native-oss.js
+1
@@ -70,6 +70,7 @@ export const enableUseMutableSource = false;
70 export const enableTransitionTracing = false;
71 export const enableSymbolFallbackForWWW = false;
72
73 +export const enableFloat = false;
74 // Flow magic to verify the exports of this file match the original version.
75 // eslint-disable-next-line no-unused-vars
76 type Check<_X, Y: _X, X: Y = _X> = null;
packages/shared/forks/ReactFeatureFlags.test-renderer.js
+1
@@ -70,6 +70,7 @@ export const enableUseMutableSource = false;
70 export const enableTransitionTracing = false;
71 export const enableSymbolFallbackForWWW = false;
72
73 +export const enableFloat = false;
74 // Flow magic to verify the exports of this file match the original version.
75 // eslint-disable-next-line no-unused-vars
76 type Check<_X, Y: _X, X: Y = _X> = null;
packages/shared/forks/ReactFeatureFlags.test-renderer.native.js
+1
@@ -68,6 +68,7 @@ export const enableUseMutableSource = false;
68 export const enableTransitionTracing = false;
69 export const enableSymbolFallbackForWWW = false;
70
71 +export const enableFloat = false;
72 // Flow magic to verify the exports of this file match the original version.
73 // eslint-disable-next-line no-unused-vars
74 type Check<_X, Y: _X, X: Y = _X> = null;
packages/shared/forks/ReactFeatureFlags.test-renderer.www.js
+1
@@ -72,6 +72,7 @@ export const enableUseMutableSource = true;
72 export const enableTransitionTracing = false;
73 export const enableSymbolFallbackForWWW = false;
74
75 +export const enableFloat = false;
76 // Flow magic to verify the exports of this file match the original version.
77 // eslint-disable-next-line no-unused-vars
78 type Check<_X, Y: _X, X: Y = _X> = null;
packages/shared/forks/ReactFeatureFlags.testing.js
+1
@@ -70,6 +70,7 @@ export const enableUseMutableSource = false;
70 export const enableTransitionTracing = false;
71 export const enableSymbolFallbackForWWW = false;
72
73 +export const enableFloat = false;
74 // Flow magic to verify the exports of this file match the original version.
75 // eslint-disable-next-line no-unused-vars
76 type Check<_X, Y: _X, X: Y = _X> = null;
packages/shared/forks/ReactFeatureFlags.testing.www.js
+1
@@ -71,6 +71,7 @@ export const enableUseMutableSource = true;
71 export const enableTransitionTracing = false;
72 export const enableSymbolFallbackForWWW = false;
73
74 +export const enableFloat = false;
75 // Flow magic to verify the exports of this file match the original version.
76 // eslint-disable-next-line no-unused-vars
77 type Check<_X, Y: _X, X: Y = _X> = null;
packages/shared/forks/ReactFeatureFlags.www-dynamic.js
+1
@@ -60,5 +60,6 @@ export const disableNativeComponentFrames = false;
60 export const createRootStrictEffectsByDefault = false;
61 export const enableStrictEffects = false;
62 export const allowConcurrentByDefault = true;
63 +export const enableFloat = false;
64 // You probably *don't* want to add more hardcoded ones.
65 // Instead, try to add them above with the __VARIANT__ value.
packages/shared/forks/ReactFeatureFlags.www.js
+1
@@ -52,6 +52,7 @@ export const enableUpdaterTracking = __PROFILE__;
52 export const enableSuspenseAvoidThisFallback = true;
53 export const enableSuspenseAvoidThisFallbackFizz = false;
54 export const enableCPUSuspense = true;
55 +export const enableFloat = false;
56
57 // Logs additional User Timing API marks for use with an experimental profiling tool.
58 export const enableSchedulingProfiler =
scripts/error-codes/codes.json
+3 -1
@@ -420,5 +420,7 @@
420 "432": "The render was aborted by the server without a reason.",
421 "433": "useId can only be used while React is rendering",
422 "434": "`dangerouslySetInnerHTML` does not make sense on <title>.",
423 - "435": "Unexpected Suspense handler tag (%s). This is a bug in React."
423 + "435": "Unexpected Suspense handler tag (%s). This is a bug in React.",
424 + "436": "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\".",
425 + "437": "the \"precedence\" prop for links to stylehseets expects to receive a string but received something of type \"%s\" instead."
426 }