[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() {