@samitouri / QOS-React-2 / commits / 5f7ef8c4cb

[Float] handle resource Resource creation inside svg context (#25599)

`title` is a valid element descendent of `svg`. this PR adds a prohibition on turning titles in svg into Resources. This PR also adds additional warnings if you render something that is almost a Resource inside an svg.

Josh Story committed Nov 1, 2022 at 14:57 UTC 5f7ef8c4cbe824ef126a947b7ae0e1c07b143357
4 files changed +449 -21
packages/react-dom-bindings/src/client/ReactDOMFloatClient.js
+81 -19
@@ -34,6 +34,8 @@ import {
34 getHostContext,
35 } from 'react-reconciler/src/ReactFiberHostContext';
36 import {getResourceFormOnly} from './validateDOMNesting';
37 +import {getNamespace} from './ReactDOMHostConfig';
38 +import {SVG_NAMESPACE} from '../shared/DOMNamespaces';
39
40 // The resource types we support. currently they match the form for the as argument.
41 // In the future this may need to change, especially when modules / scripts are supported
@@ -201,6 +203,28 @@ function getCurrentResourceRoot(): null | FloatRoot {
203 return currentContainer ? currentContainer.getRootNode() : null;
204 }
205
206 +// This resource type constraint can be loosened. It really is everything except PreloadResource
207 +// because that is the only one that does not have an optional instance type. Expand as needed.
208 +function resetInstance(resource: ScriptResource | HeadResource) {
209 + resource.instance = undefined;
210 +}
211 +
212 +export function clearRootResources(rootContainer: Container): void {
213 + const rootNode: FloatRoot = (rootContainer.getRootNode(): any);
214 + const resources = getResourcesFromRoot(rootNode);
215 +
216 + // We can't actually delete the resource cache because this function is called
217 + // during commit after we have rendered. Instead we detatch any instances from
218 + // the Resource object if they are going to be cleared
219 +
220 + // Styles stay put
221 + // Scripts get reset
222 + resources.scripts.forEach(resetInstance);
223 + // Head Resources get reset
224 + resources.head.forEach(resetInstance);
225 + // lastStructuredMeta stays put
226 +}
227 +
228 // Preloads are somewhat special. Even if we don't have the Document
229 // used by the root that is rendering a component trying to insert a preload
230 // we can still seed the file cache by doing the preload on any document we have
@@ -1077,7 +1101,14 @@ function acquireHeadResource(resource: HeadResource): Instance {
1101 props,
1102 root,
1103 );
1080 - insertResourceInstanceBefore(root, instance, titles.item(0));
1104 + const firstTitle = titles[0];
1105 + insertResourceInstanceBefore(
1106 + root,
1107 + instance,
1108 + firstTitle && firstTitle.namespaceURI !== SVG_NAMESPACE
1109 + ? firstTitle
1110 + : null,
1111 + );
1112 break;
1113 }
1114 case 'meta': {
@@ -1397,16 +1428,21 @@ function insertResourceInstanceBefore(
1428
1429 export function isHostResourceType(type: string, props: Props): boolean {
1430 let resourceFormOnly: boolean;
1431 + let namespace: string;
1432 if (__DEV__) {
1433 const hostContext = getHostContext();
1434 resourceFormOnly = getResourceFormOnly(hostContext);
1435 + namespace = getNamespace(hostContext);
1436 }
1437 switch (type) {
1438 case 'base':
1406 - case 'meta':
1407 - case 'title': {
1439 + case 'meta': {
1440 return true;
1441 }
1442 + case 'title': {
1443 + const hostContext = getHostContext();
1444 + return getNamespace(hostContext) !== SVG_NAMESPACE;
1445 + }
1446 case 'link': {
1447 const {onLoad, onError} = props;
1448 if (onLoad || onError) {
@@ -1417,6 +1453,11 @@ export function isHostResourceType(type: string, props: Props): boolean {
1453 ' Try removing onLoad={...} and onError={...} or moving it into the root <head> tag or' +
1454 ' somewhere in the <body>.',
1455 );
1456 + } else if (namespace === SVG_NAMESPACE) {
1457 + console.error(
1458 + 'Cannot render a <link> with onLoad or onError listeners as a descendent of <svg>.' +
1459 + ' Try removing onLoad={...} and onError={...} or moving it above the <svg> ancestor.',
1460 + );
1461 }
1462 }
1463 return false;
@@ -1426,11 +1467,18 @@ export function isHostResourceType(type: string, props: Props): boolean {
1467 const {href, precedence, disabled} = props;
1468 if (__DEV__) {
1469 validateLinkPropsForStyleResource(props);
1429 - if (typeof precedence !== 'string' && resourceFormOnly) {
1430 - console.error(
1431 - 'Cannot render a <link rel="stylesheet" /> outside the main document without knowing its precedence.' +
1432 - ' Consider adding precedence="default" or moving it into the root <head> tag.',
1433 - );
1470 + if (typeof precedence !== 'string') {
1471 + if (resourceFormOnly) {
1472 + console.error(
1473 + 'Cannot render a <link rel="stylesheet" /> outside the main document without knowing its precedence.' +
1474 + ' Consider adding precedence="default" or moving it into the root <head> tag.',
1475 + );
1476 + } else if (namespace === SVG_NAMESPACE) {
1477 + console.error(
1478 + 'Cannot render a <link rel="stylesheet" /> as a descendent of an <svg> element without knowing its precedence.' +
1479 + ' Consider adding precedence="default" or moving it above the <svg> ancestor.',
1480 + );
1481 + }
1482 }
1483 }
1484 return (
@@ -1450,17 +1498,31 @@ export function isHostResourceType(type: string, props: Props): boolean {
1498 // precedence with these for style resources
1499 const {src, async, onLoad, onError} = props;
1500 if (__DEV__) {
1453 - if (async !== true && resourceFormOnly) {
1454 - console.error(
1455 - 'Cannot render a sync or defer <script> outside the main document without knowing its order.' +
1456 - ' Try adding async="" or moving it into the root <head> tag.',
1457 - );
1458 - } else if ((onLoad || onError) && resourceFormOnly) {
1459 - console.error(
1460 - 'Cannot render a <script> with onLoad or onError listeners outside the main document.' +
1461 - ' Try removing onLoad={...} and onError={...} or moving it into the root <head> tag or' +
1462 - ' somewhere in the <body>.',
1463 - );
1501 + if (async !== true) {
1502 + if (resourceFormOnly) {
1503 + console.error(
1504 + 'Cannot render a sync or defer <script> outside the main document without knowing its order.' +
1505 + ' Try adding async="" or moving it into the root <head> tag.',
1506 + );
1507 + } else if (namespace === SVG_NAMESPACE) {
1508 + console.error(
1509 + 'Cannot render a sync or defer <script> as a descendent of an <svg> element.' +
1510 + ' Try adding async="" or moving it above the ancestor <svg> element.',
1511 + );
1512 + }
1513 + } else if (onLoad || onError) {
1514 + if (resourceFormOnly) {
1515 + console.error(
1516 + 'Cannot render a <script> with onLoad or onError listeners outside the main document.' +
1517 + ' Try removing onLoad={...} and onError={...} or moving it into the root <head> tag or' +
1518 + ' somewhere in the <body>.',
1519 + );
1520 + } else if (namespace === SVG_NAMESPACE) {
1521 + console.error(
1522 + 'Cannot render a <script> with onLoad or onError listeners as a descendent of an <svg> element.' +
1523 + ' Try removing onLoad={...} and onError={...} or moving it above the ancestor <svg> element.',
1524 + );
1525 + }
1526 }
1527 }
1528 return (async: any) && typeof src === 'string' && !onLoad && !onError;
packages/react-dom-bindings/src/client/ReactDOMHostConfig.js
+32 -1
@@ -88,6 +88,7 @@ import {ConcurrentMode, NoMode} from 'react-reconciler/src/ReactTypeOfMode';
88 import {
89 prepareToRenderResources,
90 cleanupAfterRenderResources,
91 + clearRootResources,
92 isHostResourceType,
93 } from './ReactDOMFloatClient';
94
@@ -219,6 +220,16 @@ export function getPublicInstance(instance: Instance): Instance {
220 return instance;
221 }
222
223 +export function getNamespace(hostContext: HostContext): string {
224 + if (__DEV__) {
225 + const hostContextDev: HostContextDev = (hostContext: any);
226 + return hostContextDev.namespace;
227 + } else {
228 + const hostContextProd: HostContextProd = (hostContext: any);
229 + return hostContextProd;
230 + }
231 +}
232 +
233 export function prepareForCommit(containerInfo: Container): Object | null {
234 eventsEnabled = ReactBrowserEventEmitterIsEnabled();
235 selectionInformation = getSelectionInformation();
@@ -715,11 +726,18 @@ export function clearContainer(container: Container): void {
726 if (enableHostSingletons) {
727 const nodeType = container.nodeType;
728 if (nodeType === DOCUMENT_NODE) {
729 + clearRootResources(container);
730 clearContainerSparingly(container);
731 } else if (nodeType === ELEMENT_NODE) {
732 switch (container.nodeName) {
733 + case 'HEAD': {
734 + // If we are clearing document.head as a container we are essentially clearing everything
735 + // that was hoisted to the head and should forget the instances that will no longer be in the DOM
736 + clearRootResources(container);
737 + // fall through to clear child contents
738 + }
739 + // eslint-disable-next-line-no-fallthrough
740 case 'HTML':
722 - case 'HEAD':
741 case 'BODY':
742 clearContainerSparingly(container);
743 return;
@@ -910,6 +928,19 @@ function getNextHydratable(node) {
928 if (nodeType === ELEMENT_NODE) {
929 const element: Element = (node: any);
930 switch (element.tagName) {
931 + // This is subtle. in SVG scope the title tag is case sensitive. we don't want to skip
932 + // titles in svg but we do want to skip them outside of svg. there is an edge case where
933 + // you could do `React.createElement('TITLE', ...)` inside an svg scope but the SSR serializer
934 + // will still emit lowercase. Practically speaking the only time the DOM will have a non-uppercased
935 + // title tagName is if it is inside an svg.
936 + // Other Resource types like META, BASE, LINK, and SCRIPT should be treated as resources even inside
937 + // svg scope because they are invalid otherwise. We still don't need to handle the lowercase variant
938 + // because if they are present in the DOM already they would have been hoisted outside the SVG scope
939 + // as Resources. So while it would be correct to skip a <link> inside <svg> and this algorithm won't
940 + // skip that link because the tagName will not be uppercased it functionally is irrelevant. If one
941 + // tries to render incompatible types such as a non-resource stylesheet inside an svg the server will
942 + // emit that invalid html and hydration will fail. In Dev this will present warnings guiding the
943 + // developer on how to fix.
944 case 'TITLE':
945 case 'META':
946 case 'BASE':
packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js
+4
@@ -1371,6 +1371,7 @@ function pushTitle(
1371 target: Array<Chunk | PrecomputedChunk>,
1372 props: Object,
1373 responseState: ResponseState,
1374 + insertionMode: InsertionMode,
1375 noscriptTagInScope: boolean,
1376 ): ReactNodeList {
1377 if (__DEV__) {
@@ -1415,6 +1416,8 @@ function pushTitle(
1416
1417 if (
1418 enableFloat &&
1419 + // title is valid in SVG so we avoid resour
1420 + insertionMode !== SVG_MODE &&
1421 !noscriptTagInScope &&
1422 resourcesFromElement('title', props)
1423 ) {
@@ -1926,6 +1929,7 @@ export function pushStartInstance(
1929 target,
1930 props,
1931 responseState,
1932 + formatContext.insertionMode,
1933 formatContext.noscriptTagInScope,
1934 )
1935 : pushStartTitle(target, props, responseState);
packages/react-dom/src/__tests__/ReactDOMFloat-test.js
+332 -1
@@ -5388,7 +5388,338 @@ describe('ReactDOMFloat', () => {
5388 });
5389 });
5390
5391 - describe('noscript', () => {
5391 + describe('resource free contexts', () => {
5392 + // @gate enableFloat
5393 + it('allows resources inside foreignobject within an svg context', async () => {
5394 + await actIntoEmptyDocument(() => {
5395 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
5396 + <html>
5397 + <body>
5398 + <svg>
5399 + <foreignObject>
5400 + <title>foo</title>
5401 + </foreignObject>
5402 + </svg>
5403 + </body>
5404 + </html>,
5405 + );
5406 + pipe(writable);
5407 + });
5408 + expect(getMeaningfulChildren(document)).toEqual(
5409 + <html>
5410 + <head>
5411 + <title>foo</title>
5412 + </head>
5413 + <body>
5414 + <svg>
5415 + <foreignobject />
5416 + </svg>
5417 + </body>
5418 + </html>,
5419 + );
5420 +
5421 + let root = ReactDOMClient.hydrateRoot(
5422 + document,
5423 + <html>
5424 + <body>
5425 + <svg>
5426 + <foreignObject>
5427 + <title>foo</title>
5428 + </foreignObject>
5429 + </svg>
5430 + </body>
5431 + </html>,
5432 + );
5433 + expect(Scheduler).toFlushWithoutYielding();
5434 + // @TODO the preload should not get inserted on hydration
5435 + expect(getMeaningfulChildren(document)).toEqual(
5436 + <html>
5437 + <head>
5438 + <title>foo</title>
5439 + </head>
5440 + <body>
5441 + <svg>
5442 + <foreignobject />
5443 + </svg>
5444 + </body>
5445 + </html>,
5446 + );
5447 +
5448 + root.unmount();
5449 + root = ReactDOMClient.createRoot(document);
5450 + root.render(
5451 + <html>
5452 + <body>
5453 + <svg>
5454 + <foreignObject>
5455 + <title>foo</title>
5456 + </foreignObject>
5457 + </svg>
5458 + </body>
5459 + </html>,
5460 + );
5461 + expect(Scheduler).toFlushWithoutYielding();
5462 + expect(getMeaningfulChildren(document)).toEqual(
5463 + <html>
5464 + <head>
5465 + <title>foo</title>
5466 + </head>
5467 + <body>
5468 + <svg>
5469 + <foreignobject />
5470 + </svg>
5471 + </body>
5472 + </html>,
5473 + );
5474 + });
5475 +
5476 + // @gate enableFloat
5477 + it('warns if you render something that is almost a resource inside an svg tree', async () => {
5478 + const root = ReactDOMClient.createRoot(container);
5479 + root.render(
5480 + <svg>
5481 + <path>
5482 + <link rel="stylesheet" href="foo" />
5483 + <script src="foo" />
5484 + <script async={true} src="bar" onLoad={() => {}} />
5485 + <link rel="foo" href="bar" onLoad={() => {}} />
5486 + </path>
5487 + </svg>,
5488 + );
5489 + expect(() => {
5490 + expect(Scheduler).toFlushWithoutYielding();
5491 + }).toErrorDev([
5492 + 'Warning: Cannot render a <link rel="stylesheet" /> as a descendent of an <svg> element without knowing its precedence. Consider adding precedence="default" or moving it above the <svg> ancestor.',
5493 + 'Warning: Cannot render a sync or defer <script> as a descendent of an <svg> element. Try adding async="" or moving it above the ancestor <svg> element.',
5494 + 'Warning: Cannot render a <script> with onLoad or onError listeners as a descendent of an <svg> element. Try removing onLoad={...} and onError={...} or moving it above the ancestor <svg> element.',
5495 + 'Warning: Cannot render a <link> with onLoad or onError listeners as a descendent of <svg>. Try removing onLoad={...} and onError={...} or moving it above the <svg> ancestor.',
5496 + ]);
5497 + });
5498 +
5499 + // @gate enableFloat
5500 + it('should support non-title resources in svg context', async () => {
5501 + await actIntoEmptyDocument(() => {
5502 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
5503 + <html>
5504 + <body>
5505 + <svg>
5506 + <title>foo</title>
5507 + <link rel="foo" href="bar" />
5508 + <link rel="stylesheet" href="bar" precedence="default" />
5509 + <link rel="preload" href="bar" as="style" />
5510 + <path>
5511 + <title>bar</title>
5512 + <script src="baz" async={true} />
5513 + <meta name="foo" content="bar" />
5514 + <base href="foo" />
5515 + </path>
5516 + </svg>
5517 + <title>qux</title>
5518 + </body>
5519 + </html>,
5520 + );
5521 + pipe(writable);
5522 + });
5523 + expect(getMeaningfulChildren(document)).toEqual(
5524 + <html>
5525 + <head>
5526 + <base href="foo" />
5527 + <link rel="stylesheet" href="bar" data-precedence="default" />
5528 + <script src="baz" async="" />
5529 + <link rel="foo" href="bar" />
5530 + <meta name="foo" content="bar" />
5531 + <title>qux</title>
5532 + </head>
5533 + <body>
5534 + <svg>
5535 + <title>foo</title>
5536 + <path>
5537 + <title>bar</title>
5538 + </path>
5539 + </svg>
5540 + </body>
5541 + </html>,
5542 + );
5543 +
5544 + let root = ReactDOMClient.hydrateRoot(
5545 + document,
5546 + <html>
5547 + <body>
5548 + <svg>
5549 + <title>foo</title>
5550 + <link rel="foo" href="bar" />
5551 + <link rel="stylesheet" href="bar" precedence="default" />
5552 + <link rel="preload" href="bar" as="style" />
5553 + <path>
5554 + <title>bar</title>
5555 + <script src="baz" async={true} />
5556 + <meta name="foo" content="bar" />
5557 + <base href="foo" />
5558 + </path>
5559 + </svg>
5560 + <title>qux</title>
5561 + </body>
5562 + </html>,
5563 + );
5564 + expect(Scheduler).toFlushWithoutYielding();
5565 + // @TODO the preload should not get inserted on hydration
5566 + expect(getMeaningfulChildren(document)).toEqual(
5567 + <html>
5568 + <head>
5569 + <base href="foo" />
5570 + <link rel="stylesheet" href="bar" data-precedence="default" />
5571 + <script src="baz" async="" />
5572 + <link rel="foo" href="bar" />
5573 + <meta name="foo" content="bar" />
5574 + <title>qux</title>
5575 + <link rel="preload" href="bar" as="style" />
5576 + </head>
5577 + <body>
5578 + <svg>
5579 + <title>foo</title>
5580 + <path>
5581 + <title>bar</title>
5582 + </path>
5583 + </svg>
5584 + </body>
5585 + </html>,
5586 + );
5587 +
5588 + root.unmount();
5589 + root = ReactDOMClient.createRoot(document);
5590 + root.render(
5591 + <html>
5592 + <body>
5593 + <svg>
5594 + <title>foo</title>
5595 + <link rel="foo" href="bar" />
5596 + <link rel="stylesheet" href="bar" precedence="default" />
5597 + <link rel="preload" href="bar" as="style" />
5598 + <path>
5599 + <title>bar</title>
5600 + <script src="baz" async={true} />
5601 + <meta name="foo" content="bar" />
5602 + <base href="foo" />
5603 + </path>
5604 + </svg>
5605 + <title>qux</title>
5606 + </body>
5607 + </html>,
5608 + );
5609 + expect(Scheduler).toFlushWithoutYielding();
5610 + expect(getMeaningfulChildren(document)).toEqual(
5611 + <html>
5612 + <head>
5613 + <link rel="stylesheet" href="bar" data-precedence="default" />
5614 + <link rel="foo" href="bar" />
5615 + <script src="baz" async="" />
5616 + <meta name="foo" content="bar" />
5617 + <base href="foo" />
5618 + <title>qux</title>
5619 + </head>
5620 + <body>
5621 + <svg>
5622 + <title>foo</title>
5623 + <path>
5624 + <title>bar</title>
5625 + </path>
5626 + </svg>
5627 + </body>
5628 + </html>,
5629 + );
5630 + });
5631 +
5632 + // @gate enableFloat
5633 + it('should not treat title descendants of svg into resources', async () => {
5634 + await actIntoEmptyDocument(() => {
5635 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
5636 + <html>
5637 + <body>
5638 + <svg>
5639 + <title>foo</title>
5640 + <path>
5641 + <title>bar</title>
5642 + </path>
5643 + </svg>
5644 + </body>
5645 + </html>,
5646 + );
5647 + pipe(writable);
5648 + });
5649 + expect(getMeaningfulChildren(document)).toEqual(
5650 + <html>
5651 + <head />
5652 + <body>
5653 + <svg>
5654 + <title>foo</title>
5655 + <path>
5656 + <title>bar</title>
5657 + </path>
5658 + </svg>
5659 + </body>
5660 + </html>,
5661 + );
5662 +
5663 + let root = ReactDOMClient.hydrateRoot(
5664 + document,
5665 + <html>
5666 + <head />
5667 + <body>
5668 + <svg>
5669 + <title>foo</title>
5670 + <path>
5671 + <title>bar</title>
5672 + </path>
5673 + </svg>
5674 + </body>
5675 + </html>,
5676 + );
5677 + expect(Scheduler).toFlushWithoutYielding();
5678 + expect(getMeaningfulChildren(document)).toEqual(
5679 + <html>
5680 + <head />
5681 + <body>
5682 + <svg>
5683 + <title>foo</title>
5684 + <path>
5685 + <title>bar</title>
5686 + </path>
5687 + </svg>
5688 + </body>
5689 + </html>,
5690 + );
5691 +
5692 + root.unmount();
5693 + root = ReactDOMClient.createRoot(document);
5694 + root.render(
5695 + <html>
5696 + <head />
5697 + <body>
5698 + <svg>
5699 + <title>foo</title>
5700 + <path>
5701 + <title>bar</title>
5702 + </path>
5703 + </svg>
5704 + </body>
5705 + </html>,
5706 + );
5707 + expect(Scheduler).toFlushWithoutYielding();
5708 + expect(getMeaningfulChildren(document)).toEqual(
5709 + <html>
5710 + <head />
5711 + <body>
5712 + <svg>
5713 + <title>foo</title>
5714 + <path>
5715 + <title>bar</title>
5716 + </path>
5717 + </svg>
5718 + </body>
5719 + </html>,
5720 + );
5721 + });
5722 +
5723 // @gate enableFloat
5724 it('should not turn children of noscript into resources', async () => {
5725 function SomeResources() {